Shahd1sayed commited on
Commit
0f12291
Β·
verified Β·
1 Parent(s): 1a5a4a3

Deploy v2: multimodal ensemble router (Framingham tabular + ECG ResNet)

Browse files
Dockerfile CHANGED
@@ -1,18 +1,19 @@
1
- FROM python:3.11-slim
2
-
3
- WORKDIR /app
4
-
5
- # Install dependencies
6
- COPY requirements.txt .
7
- RUN pip install --no-cache-dir -r requirements.txt
8
-
9
- # Copy application files
10
- COPY app.py .
11
- COPY model.pkl .
12
- COPY static/ static/
13
-
14
- # Hugging Face Spaces expects port 7860
15
- EXPOSE 7860
16
-
17
- # Start the server
18
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application files (v2 multimodal ensemble router)
10
+ COPY app.py .
11
+ COPY inference/ inference/
12
+ COPY models/ models/
13
+ COPY static/ static/
14
+
15
+ # Hugging Face Spaces expects port 7860
16
+ EXPOSE 7860
17
+
18
+ # Start the server
19
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,432 +1,289 @@
1
- ---
2
- title: Heart Attack Risk Predictor
3
- emoji: πŸ«€
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- app_file: app.py
8
- pinned: false
9
- ---
10
-
11
- <h1 align="center">πŸ«€ Heart Attack Risk Predictor</h1>
12
-
13
- <p align="center">
14
- <strong>An AI-powered clinical decision-support tool that predicts a patient's heart attack risk level (High, Moderate, or Low) based on 8 vital signs and cardiac biomarkers, served as a real-time web application powered by a Random Forest classifier and FastAPI.</strong>
15
- </p>
16
-
17
- <p align="center">
18
- <a href="https://huggingface.co/spaces/Shahd1sayed/heart-attack-risk-predictor"><strong>πŸ”΄ TRY THE LIVE DEMO ON HUGGING FACE SPACES πŸ”΄</strong></a>
19
- </p>
20
-
21
- ---
22
-
23
- ## πŸ“‘ Table of Contents
24
-
25
- - [Key Features](#-key-features)
26
- - [Medical Background](#-medical-background)
27
- - [Tech Stack](#-tech-stack)
28
- - [Machine Learning Pipeline](#-machine-learning-pipeline)
29
- - [Project Structure](#-project-structure)
30
- - [Getting Started](#-getting-started)
31
- - [Usage](#-usage)
32
- - [Model Performance](#-model-performance)
33
- - [Dataset Description](#-dataset-description)
34
- - [Feature Importance](#-feature-importance)
35
- - [Team Members](#-team-members)
36
-
37
- ---
38
-
39
- ## ✨ Key Features
40
-
41
- - **Real-Time Risk Prediction** β€” Enter 8 patient vitals and receive an instant heart attack risk classification (High / Moderate / Low) with confidence probabilities.
42
- - **Production-Grade REST API** β€” Built with FastAPI, featuring automatic request validation via Pydantic schemas, interactive Swagger docs, and ASGI-based async performance.
43
- - **Modern UI** β€” A dark-themed, responsive frontend with animated gradient blobs, color-coded risk badges (πŸ”΄ High / 🟑 Moderate / 🟒 Low), and animated probability bars.
44
- - **Bundled Model Artifact** β€” The trained model, scaler, and label encoders are serialized into a single `model.pkl` for zero-configuration deployment.
45
- - **Clinically Interpretable** β€” Feature importance rankings show exactly which vitals drive predictions, aligning with established cardiology literature.
46
-
47
- ---
48
-
49
- ## πŸ₯ Medical Background
50
-
51
- ### What Is a Heart Attack?
52
-
53
- A **heart attack** (myocardial infarction, MI) occurs when blood flow to a section of the heart muscle is blocked for an extended period, causing the heart tissue to die. It is the leading cause of death globally, accounting for approximately **17.9 million deaths per year** according to the World Health Organization (WHO).
54
-
55
- ### Clinical Biomarkers Used in This Model
56
-
57
- This application leverages the same vital signs and biomarkers that emergency physicians use to triage cardiac patients:
58
-
59
- | Biomarker | Clinical Significance | Normal Range |
60
- |---|---|---|
61
- | **Age** | Risk increases significantly after age 45 (men) and 55 (women). Aging contributes to arterial stiffness, plaque buildup, and decreased cardiac output. | N/A |
62
- | **Gender** | Men have a statistically higher risk of MI at younger ages. Post-menopausal women see a sharp increase in risk due to declining estrogen levels. | Male / Female |
63
- | **Heart Rate** | Resting tachycardia (>100 bpm) is associated with increased cardiovascular mortality. Bradycardia (<60 bpm) may indicate conduction abnormalities. | 60–100 bpm |
64
- | **Systolic Blood Pressure** | Elevated systolic BP (hypertension) is a primary modifiable risk factor. It places chronic stress on arterial walls, accelerating atherosclerosis. | 90–120 mmHg |
65
- | **Diastolic Blood Pressure** | Elevated diastolic BP reduces coronary perfusion pressure and is independently associated with increased MI risk, especially in younger adults. | 60–80 mmHg |
66
- | **Blood Sugar (Glucose)** | Hyperglycemia (diabetes) accelerates endothelial dysfunction and atherosclerotic plaque formation. Diabetic patients have a 2–4Γ— higher risk of cardiovascular events. | 70–140 mg/dL |
67
- | **CK-MB (Creatine Kinase-MB)** | A cardiac enzyme released into the bloodstream when the heart muscle is damaged. Elevated CK-MB (>5 ng/mL) within 4–8 hours of chest pain is a strong indicator of myocardial injury. | 0–5 ng/mL |
68
- | **Troponin** | The **gold standard** biomarker for diagnosing acute MI. Cardiac troponins (cTnI / cTnT) are structural proteins released during myocardial necrosis. Even slight elevations (>0.04 ng/mL) are diagnostic. Troponin levels peak at 12–24 hours post-MI. | <0.04 ng/mL |
69
-
70
- ### Why Troponin and CK-MB Dominate
71
-
72
- In our trained model, **Troponin** (43.3% importance) and **CK-MB** (20.7% importance) together account for **64%** of the model's decision-making. This is medically consistent β€” cardiologists rely heavily on these two biomarkers to diagnose and classify MI severity. The `Risk_Level` labels in the dataset are likely derived from clinical threshold rules applied to these biomarkers, which explains the model's exceptionally high accuracy (~98.6%).
73
-
74
- ---
75
-
76
- ## πŸ› οΈ Tech Stack
77
-
78
- | Layer | Technology | Purpose |
79
- |---|---|---|
80
- | **Backend** | [FastAPI](https://fastapi.tiangolo.com/) | High-performance ASGI web framework with automatic OpenAPI docs |
81
- | **Server** | [Uvicorn](https://www.uvicorn.org/) | Lightning-fast ASGI server for production HTTP serving |
82
- | **ML Model** | [scikit-learn](https://scikit-learn.org/) `RandomForestClassifier` | Ensemble learning method for multi-class classification |
83
- | **Preprocessing** | [scikit-learn](https://scikit-learn.org/) `StandardScaler`, `LabelEncoder` | Feature normalization and categorical encoding |
84
- | **Model Comparison** | [XGBoost](https://xgboost.readthedocs.io/) | Gradient boosting framework used for benchmarking |
85
- | **Data Handling** | [pandas](https://pandas.pydata.org/), [NumPy](https://numpy.org/) | DataFrame manipulation and numerical computation |
86
- | **Validation** | [Pydantic](https://docs.pydantic.dev/) | Data validation and serialization for API request/response |
87
- | **Serialization** | Python `pickle` | Model persistence and bundling |
88
- | **Frontend** | HTML5, CSS3, Vanilla JavaScript | Responsive single-page application with glassmorphism design |
89
-
90
- ---
91
-
92
- ## πŸ”¬ Exploratory Data Analysis (EDA)
93
-
94
- The `research_and_experiments/heart_attack.ipynb` notebook contains a complete clinical EDA. Before training models, we analyzed the dataset visually to understand the true drivers of prediction:
95
-
96
- 1. **Target Distribution:** The dataset is heavily skewed towards "High" Risk (61%), indicating collection from an acute care setting rather than a general population screening.
97
- 2. **Demographics & Vitals:** Analysis showed that Age, Gender, Blood Pressure, and Heart Rate distributions overlap heavily across all risk categories. They are very poor independent predictors in this dataset.
98
- 3. **The Smoking Gun (Biomarkers):** Log-scale visualizations of **Troponin** and **CK-MB** revealed the true predictive signal. Every single "Low" risk patient is clustered at the bottom, while "Moderate" and "High" risk explode upward past clinical diagnostic cutoffs.
99
- 4. **Conclusion:** A mathematical correlation heatmap proved that the targets (`Risk_Level`) were likely labeled using medical threshold rules applied directly to Troponin and CK-MB.
100
-
101
- ---
102
-
103
- ## πŸ€– Machine Learning Pipeline & Model Architecture
104
-
105
- The core of this application is a **Random Forest Classifier**. We chose this architecture because it is highly robust against overfitting, handles non-linear relationships well, and naturally provides feature importance rankings (which is critical for medical explainability).
106
-
107
- The training pipeline (`research_and_experiments/train_model.py`) follows a rigorous, multi-stage workflow:
108
-
109
- ### Stage 1 β€” Data Loading & Inspection
110
- ```
111
- Raw dataset: 1,319 rows Γ— 11 columns
112
- Target variable: Risk_Level (High / Moderate / Low)
113
- ```
114
-
115
- ### Stage 2 β€” Outlier Cleaning & Physiological Bounding
116
- Before training, we applied strict physiological bounds to remove erroneous data entries that could skew the model's decision trees:
117
-
118
- | Feature | Valid Range | Outliers Removed |
119
- |---|---|---|
120
- | Heart rate | 20–300 bpm | 3 (e.g., one row had 1,111 bpm) |
121
- | Systolic BP | 50–300 mmHg | 1 |
122
- | Age | 1–120 years | 0 |
123
- | CK-MB | 0–500 ng/mL | 0 |
124
- | Troponin | 0–50 ng/mL | 0 |
125
-
126
- **Result:** 1,319 β†’ 1,315 rows (4 outliers removed)
127
-
128
- ### Stage 3 β€” Encoding & Feature Scaling
129
- Machine learning models perform best when numerical data is on a similar scale. We separated the data into features (`X`) and target (`y`), and applied the following transformations:
130
- 1. **Categorical Encoding:** `LabelEncoder` converted Gender (Female β†’ 0, Male β†’ 1) and Risk_Level (High β†’ 0, Low β†’ 1, Moderate β†’ 2).
131
- 2. **Standardization:** We applied `scikit-learn`'s `StandardScaler` to all 8 feature columns. This forces each column to have a mean of `0` and a variance of `1`.
132
- > *Crucially, this exact `StandardScaler` object was fitted on the training data and saved into the final bundle, ensuring that real-time API requests are scaled using the exact same mathematical parameters.*
133
-
134
- ### Stage 4 β€” Model Selection & Hyperparameter Tuning (5-Fold Stratified CV)
135
- We did not just assume Random Forest was the best. We rigorously tested four different model configurations using **5-Fold Stratified Cross-Validation**. Stratification ensures each of the 5 testing folds maintains the exact class distribution (61% High, 21% Low, 18% Moderate) as the entire dataset.
136
-
137
- | Model | Hyperparameters | Mean CV Accuracy | Std Dev |
138
- |---|---|---|---|
139
- | **Random Forest (default)** | `n_estimators=200`, `max_depth=None` | **98.63%** | Β±0.52% |
140
- | Random Forest (balanced) | `class_weight='balanced'` | 98.48% | Β±0.59% |
141
- | XGBoost (default) | `learning_rate=0.1`, `max_depth=6` | 98.48% | Β±0.48% |
142
- | XGBoost (balanced) | Default + `sample_weight` computed | 98.63% | Β±0.52% |
143
-
144
- ### Stage 5 β€” Final Training & Bundle Export
145
- The winning model configuration (**Random Forest, 200 estimators, no strict class weighting**) was selected because it achieved the highest accuracy while naturally resisting the dataset's mild class imbalance through ensemble bagging.
146
-
147
- The model was retrained on the *entire* cleaned dataset. Finally, we used Python's `pickle` library to bundle four distinct objects into a single file (`model.pkl`):
148
- 1. The trained `RandomForestClassifier`
149
- 2. The fitted `StandardScaler`
150
- 3. The `LabelEncoder` for Gender
151
- 4. The `LabelEncoder` for Risk_Level
152
-
153
- By bundling them together, the FastAPI backend can flawlessly preprocess incoming API JSON requests identically to the training pipeline before running `model.predict()`.
154
-
155
- ---
156
-
157
- ## πŸ“ Project Structure
158
-
159
- ```
160
- heart-attack-risk-predictor/
161
- β”‚
162
- β”œβ”€β”€ app.py # FastAPI backend β€” loads model, serves predictions & frontend
163
- β”œβ”€β”€ model.pkl # Serialized bundle: RF model + StandardScaler + LabelEncoders
164
- β”œβ”€β”€ requirements.txt # Production Python dependencies
165
- β”‚
166
- β”œβ”€β”€ static/ # Frontend assets
167
- β”‚ └── index.html # Single-page app with glassmorphism UI
168
- β”‚
169
- └── research_and_experiments/ # Exploratory analysis & training (not needed for production)
170
- β”œβ”€β”€ heart_attack.ipynb # Jupyter notebook with EDA and visualizations
171
- β”œβ”€β”€ Heart_Attack_Risk_Levels_Dataset.csv # Raw dataset (1,319 patient records)
172
- β”œβ”€β”€ deploy_to_hf.py # Python script for pushing securely to HF Spaces via API
173
- β”œβ”€β”€ train_model.py # Full training pipeline with diagnostics
174
- β”œβ”€β”€ save_as_pkl.py # Script to bundle model artifacts into model.pkl
175
- β”œβ”€β”€ output.txt # Training output logs
176
- β”œβ”€β”€ random_forest_model.joblib # Individual model artifact (legacy)
177
- β”œβ”€β”€ label_encoder_gender.joblib # Individual encoder artifact (legacy)
178
- └── label_encoder_risk_level.joblib # Individual encoder artifact (legacy)
179
- ```
180
-
181
- ### File Descriptions
182
-
183
- | File | Size | Description |
184
- |---|---|---|
185
- | `app.py` | 3 KB | The core application server. Loads the model bundle at startup, exposes a `POST /predict` endpoint, and serves the static frontend at `/`. |
186
- | `model.pkl` | 2.6 MB | A Python pickle bundle containing four objects: the trained `RandomForestClassifier`, the fitted `StandardScaler`, the `LabelEncoder` for gender, and the `LabelEncoder` for risk level. |
187
- | `requirements.txt` | <1 KB | Pinned production dependencies: `fastapi`, `uvicorn`, `scikit-learn`, `numpy`, `pydantic`. |
188
- | `static/index.html` | ~9 KB | A self-contained HTML/CSS/JS frontend with dark theme, animated gradient background, input validation, and dynamic result rendering. |
189
-
190
- ---
191
-
192
- ## πŸš€ Getting Started
193
-
194
- ### Prerequisites
195
-
196
- | Requirement | Minimum Version | Check Command |
197
- |---|---|---|
198
- | Python | 3.9+ | `python --version` |
199
- | pip | 21.0+ | `pip --version` |
200
-
201
- ### Step-by-Step Installation
202
-
203
- **1. Clone the repository**
204
- ```bash
205
- git clone https://github.com/Shahd1Sayed/heart-attack-risk-predictor.git
206
- ```
207
-
208
- **2. Navigate to the project directory**
209
- ```bash
210
- cd heart-attack-risk-predictor
211
- ```
212
-
213
- **3. Create a virtual environment (recommended)**
214
- ```bash
215
- # On Windows
216
- python -m venv .venv
217
- .venv\Scripts\activate
218
-
219
- # On macOS / Linux
220
- python3 -m venv .venv
221
- source .venv/bin/activate
222
- ```
223
-
224
- **4. Install dependencies**
225
- ```bash
226
- pip install -r requirements.txt
227
- ```
228
-
229
- **5. Verify the model file exists**
230
- ```bash
231
- # Ensure model.pkl is present in the root directory
232
- ls model.pkl # macOS/Linux
233
- dir model.pkl # Windows
234
- ```
235
-
236
- > **Note:** If `model.pkl` is missing (e.g., excluded by `.gitignore` due to its size), you can regenerate it by running the training pipeline:
237
- > ```bash
238
- > cd research_and_experiments
239
- > pip install pandas xgboost # additional training dependencies
240
- > python train_model.py
241
- > mv model.pkl ../ # move the generated model to root
242
- > cd ..
243
- > ```
244
-
245
- ---
246
-
247
- ## ▢️ Usage
248
-
249
- ### Start the server
250
- ```bash
251
- python -m uvicorn app:app --host 127.0.0.1 --port 8000
252
- ```
253
-
254
- ### Access the application
255
- Open your browser and navigate to:
256
- ```
257
- http://127.0.0.1:8000
258
- ```
259
-
260
- ### Using the Web Interface
261
- 1. Fill in the 8 patient vitals in the form fields.
262
- 2. Click **"Predict Risk Level"**.
263
- 3. View the color-coded result badge and probability distribution bars.
264
-
265
- ### Using the API directly (cURL)
266
- ```bash
267
- curl -X POST http://127.0.0.1:8000/predict \
268
- -H "Content-Type: application/json" \
269
- -d '{
270
- "age": 63,
271
- "gender": "Male",
272
- "heart_rate": 66,
273
- "systolic_bp": 160,
274
- "diastolic_bp": 83,
275
- "blood_sugar": 160,
276
- "ck_mb": 1.8,
277
- "troponin": 0.012
278
- }'
279
- ```
280
-
281
- ### Example Response
282
- ```json
283
- {
284
- "risk_level": "Moderate",
285
- "probabilities": {
286
- "High": 0.045,
287
- "Moderate": 0.925,
288
- "Low": 0.03
289
- }
290
- }
291
- ```
292
-
293
- ---
294
-
295
- ### Endpoints
296
-
297
- #### `GET /`
298
- Serves the frontend HTML application.
299
-
300
- | Parameter | Value |
301
- |---|---|
302
- | **Method** | `GET` |
303
- | **Response** | `text/html` β€” The single-page application |
304
- | **Status** | `200 OK` |
305
-
306
- ---
307
-
308
- #### `POST /predict`
309
- Accepts patient vitals and returns the predicted heart attack risk level with class probabilities.
310
-
311
- **Request Body** (`application/json`):
312
-
313
- | Field | Type | Required | Description | Example |
314
- |---|---|---|---|---|
315
- | `age` | `integer` | βœ… | Patient's age in years | `63` |
316
- | `gender` | `string` | βœ… | `"Male"` or `"Female"` | `"Male"` |
317
- | `heart_rate` | `integer` | βœ… | Resting heart rate in bpm | `66` |
318
- | `systolic_bp` | `integer` | βœ… | Systolic blood pressure in mmHg | `160` |
319
- | `diastolic_bp` | `integer` | βœ… | Diastolic blood pressure in mmHg | `83` |
320
- | `blood_sugar` | `integer` | βœ… | Fasting blood sugar in mg/dL | `160` |
321
- | `ck_mb` | `float` | βœ… | CK-MB enzyme level in ng/mL | `1.8` |
322
- | `troponin` | `float` | βœ… | Cardiac troponin level in ng/mL | `0.012` |
323
-
324
- **Response Body** (`application/json`):
325
-
326
- | Field | Type | Description |
327
- |---|---|---|
328
- | `risk_level` | `string` | Predicted class: `"High"`, `"Moderate"`, or `"Low"` |
329
- | `probabilities` | `object` | Confidence scores for each class (sum to 1.0) |
330
-
331
- **Response Status Codes:**
332
-
333
- | Code | Meaning |
334
- |---|---|
335
- | `200 OK` | Prediction returned successfully |
336
- | `422 Unprocessable Entity` | Invalid or missing request fields (Pydantic validation error) |
337
-
338
- ---
339
-
340
- ## πŸ“Š Model Performance
341
-
342
- ### Cross-Validation Results (5-Fold Stratified)
343
-
344
- | Metric | Value |
345
- |---|---|
346
- | **Best Model** | Random Forest (200 estimators) |
347
- | **Mean CV Accuracy** | 98.63% |
348
- | **Standard Deviation** | Β±0.52% |
349
- | **Train Accuracy** | 100.00% |
350
- | **Train-Test Gap** | ~1.4% (minor overfitting) |
351
-
352
- ### Class Distribution in Dataset
353
-
354
- | Risk Level | Count | Percentage |
355
- |---|---|---|
356
- | High | 809 | 61.5% |
357
- | Low | 275 | 20.9% |
358
- | Moderate | 231 | 17.6% |
359
-
360
- ### Accuracy Transparency Note
361
-
362
- > ⚠️ **Important:** The high accuracy (~98.6%) is attributable to the dataset's construction. Analysis revealed that the `Risk_Level` labels are strongly correlated with β€” and likely derived from β€” clinical threshold rules applied to **Troponin** and **CK-MB** values. Removing these two features drops accuracy to **62.17%**. This means the model is effectively learning decision boundaries that mirror clinical diagnostic criteria, which is valid for a decision-support tool but should not be interpreted as the model discovering novel medical insights.
363
-
364
- ---
365
-
366
- ## πŸ“‹ Dataset Description
367
-
368
- - **Source:** Heart Attack Risk Levels Dataset
369
- - **Records:** 1,319 patient observations (1,315 after outlier removal)
370
- - **Features:** 8 clinical input variables + 1 target variable
371
- - **Target Variable:** `Risk_Level` β€” a 3-class categorical label
372
-
373
- ### Full Schema
374
-
375
- | Column | Data Type | Range / Values | Description |
376
- |---|---|---|---|
377
- | `Age` | Integer | 14–98 | Patient age in years |
378
- | `Gender` | Binary | 0 (Female), 1 (Male) | Biological sex |
379
- | `Heart rate` | Integer | 20–300 | Resting heart rate (bpm) |
380
- | `Systolic blood pressure` | Integer | 50–300 | Systolic BP (mmHg) |
381
- | `Diastolic blood pressure` | Integer | 20–200 | Diastolic BP (mmHg) |
382
- | `Blood sugar` | Integer | 20–700 | Fasting blood glucose (mg/dL) |
383
- | `CK-MB` | Float | 0–500 | Creatine Kinase-MB isoenzyme (ng/mL) |
384
- | `Troponin` | Float | 0–50 | Cardiac troponin concentration (ng/mL) |
385
- | `Risk_Level` | Categorical | High / Moderate / Low | **Target** β€” Heart attack risk classification |
386
-
387
- ---
388
-
389
- ## πŸ” Feature Importance
390
-
391
- The following chart shows how much each feature contributes to the model's predictions (measured by Gini impurity decrease in the Random Forest):
392
-
393
- ```
394
- Troponin β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 43.3%
395
- CK-MB β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 20.7%
396
- Systolic blood pressure β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 13.4%
397
- Blood sugar β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 11.1%
398
- Age β–ˆβ–ˆ 4.3%
399
- Diastolic blood pressure β–ˆβ–ˆ 4.0%
400
- Heart rate β–ˆ 2.5%
401
- Gender ▏ 0.7%
402
- ```
403
-
404
- ### Clinical Interpretation
405
-
406
- 1. **Troponin (43.3%)** β€” Aligns with its role as the gold-standard biomarker for MI diagnosis. The European Society of Cardiology (ESC) guidelines classify MI severity primarily based on troponin thresholds.
407
- 2. **CK-MB (20.7%)** β€” Historically the primary cardiac biomarker before high-sensitivity troponin assays became standard. Still used as a complementary marker.
408
- 3. **Blood Pressure (17.4% combined)** β€” Hypertension is the single most prevalent modifiable risk factor for cardiovascular disease.
409
- 4. **Blood Sugar (11.1%)** β€” Reflects the well-established link between diabetes/metabolic syndrome and cardiovascular risk.
410
- 5. **Age (4.3%)** β€” A non-modifiable risk factor captured by the Framingham Risk Score.
411
- 6. **Gender (0.7%)** β€” Low importance suggests the dataset may not fully capture sex-based cardiovascular differences.
412
-
413
- ---
414
-
415
-
416
- ### Development Setup
417
-
418
- To work on the training pipeline, install the additional research dependencies:
419
- ```bash
420
- pip install pandas xgboost jupyter matplotlib seaborn
421
- ```
422
-
423
- ---
424
-
425
- ## πŸ‘₯ Team Members
426
-
427
- | Name | Role | GitHub |
428
- |---|---|---|
429
- | **Shahd Sayed** | Machine Learning Engineer | [@Shahd1Sayed](https://github.com/Shahd1Sayed) |
430
- | **Shahd Mohammed** | Full-Stack Developer | |
431
-
432
- ---
 
1
+ ---
2
+ title: Heart Attack Risk Predictor
3
+ emoji: πŸ«€
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ <h1 align="center">πŸ«€ Heart Attack Risk Predictor β€” Multimodal</h1>
12
+
13
+ <p align="center">
14
+ <strong>An AI-powered clinical decision-support tool that estimates a patient's heart-attack risk from <em>tabular patient data</em>, an <em>ECG image</em>, or <em>both</em> β€” using an ensemble-router architecture with a tabular model, an ECG image model, and late-fusion of their scores.</strong>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://huggingface.co/spaces/Shahd1sayed/heart-attack-risk-predictor"><strong>πŸ”΄ TRY THE LIVE DEMO ON HUGGING FACE SPACES πŸ”΄</strong></a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <em>⚠️ Educational / portfolio demo β€” <strong>not a medical device</strong>. Do not use for real clinical decisions.</em>
23
+ </p>
24
+
25
+ ---
26
+
27
+ ## πŸ“‘ Table of Contents
28
+
29
+ - [What's New in v2](#-whats-new-in-v2)
30
+ - [Key Features](#-key-features)
31
+ - [How It Works β€” Architecture](#-how-it-works--architecture)
32
+ - [The Two Models](#-the-two-models)
33
+ - [Tech Stack](#-tech-stack)
34
+ - [Project Structure](#-project-structure)
35
+ - [Getting Started](#-getting-started)
36
+ - [Usage & API](#-usage--api)
37
+ - [Model Performance](#-model-performance)
38
+ - [Testing](#-testing)
39
+ - [Limitations & Honesty](#-limitations--honesty)
40
+ - [Team Members](#-team-members)
41
+
42
+ ---
43
+
44
+ ## πŸ†• What's New in v2
45
+
46
+ Version 1 was a single-model biomarker classifier (8 vitals β†’ Random Forest). A
47
+ leakage test showed its ~98% accuracy was largely a biomarker-threshold rule
48
+ (accuracy fell to ~62% without Troponin & CK-MB). Version 2 re-architects the project
49
+ into a **multimodal ensemble router**:
50
+
51
+ - **Two models** instead of one β€” a **tabular** model and an **ECG image** model.
52
+ - **A router** that picks the model(s) based on what the user submits, and **averages**
53
+ their scores when both are provided.
54
+ - **Honest evaluation** β€” proper metrics (ROC-AUC for the imbalanced tabular task,
55
+ per-class metrics for the ECG task) and a clear statement of limitations.
56
+
57
+ > The original v1 biomarker research is preserved in `research_and_experiments/`.
58
+
59
+ ---
60
+
61
+ ## ✨ Key Features
62
+
63
+ - **Multimodal input** β€” enter patient data, upload an ECG image, or do both.
64
+ - **Ensemble router** β€” one `POST /predict` endpoint routes to the right model(s):
65
+ tabular β†’ Model A, ECG β†’ Model B, both β†’ averaged score.
66
+ - **Missing-data friendly** β€” blank tabular fields are filled automatically by
67
+ **K-Nearest-Neighbours imputation**, so a partial form still works.
68
+ - **Server-side validation** β€” out-of-range or non-numeric fields are rejected with a
69
+ clear message (HTTP 422).
70
+ - **ECG confidence check** β€” low-confidence ECG predictions are flagged ("may not be a
71
+ clear 12-lead ECG").
72
+ - **Transparent results** β€” the UI shows each model's score and, in "both" mode, the
73
+ combined average, so nothing is a black box.
74
+ - **Modern UI** β€” dark glassmorphism theme, drag-and-drop ECG upload, color-coded risk
75
+ badges (πŸ”΄ High / 🟑 Moderate / 🟒 Low).
76
+
77
+ ---
78
+
79
+ ## 🧠 How It Works β€” Architecture
80
+
81
+ ```
82
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ POST /predict (multipart/form-data) ────────────────┐
83
+ tabular only ──→ Model A (Framingham: KNN-impute β†’ scale β†’ RandomForest) β†’ p_a ─┐ β”‚
84
+ β”‚ β”œβ”€ both β†’ average β†’ p β†’ band (Low/Mod/High)
85
+ ECG only ──────→ Model B (ResNet18 transfer learning on ECG images) β†’ p_b β”€β”˜ β”‚
86
+ β”‚ β”‚
87
+ neither ───────→ HTTP 400 β”‚
88
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
89
+ ```
90
+
91
+ Each model outputs a scalar **risk probability** `p ∈ [0, 1]`. That value is mapped to
92
+ a band β€” **Low** (`< 0.33`), **Moderate** (`0.33–0.66`), **High** (`> 0.66`) β€” and, when
93
+ both models run, the two scores are combined by an equal-weight average.
94
+
95
+ ---
96
+
97
+ ## πŸ€– The Two Models
98
+
99
+ ### Model A β€” Tabular (Framingham 10-year CHD)
100
+ - **Data:** Framingham Heart Study (4,240 patients, 15 clinical features).
101
+ - **Target:** `TenYearCHD` β€” probability of coronary heart disease within 10 years.
102
+ - **Pipeline:** `StandardScaler β†’ KNNImputer(k=5) β†’ classifier`, saved as one artifact.
103
+ - **Model selection:** RandomForest vs XGBoost by 5-fold CV ROC-AUC β€” **RandomForest
104
+ won (0.69 vs 0.67)**.
105
+ - **Handles imbalance** (~15% positive) with class weighting; the app uses the
106
+ continuous probability, not a hard 0.5 cutoff.
107
+
108
+ ### Model B β€” ECG image (ResNet18)
109
+ - **Data:** ECG Images Dataset of Cardiac Patients (928 images, 4 classes).
110
+ - **Method:** **transfer learning** β€” a ResNet18 pretrained on ImageNet, with a new
111
+ 4-class head; train the head, then fine-tune the last block.
112
+ - **Classes β†’ risk weight:** Normal `0.0`, abnormal heartbeat `0.5`, post-MI history
113
+ `0.8`, myocardial infarction `1.0`. The 4-class softmax is collapsed to one risk
114
+ score via these weights.
115
+
116
+ ---
117
+
118
+ ## πŸ› οΈ Tech Stack
119
+
120
+ | Layer | Technology | Purpose |
121
+ |---|---|---|
122
+ | **Backend** | [FastAPI](https://fastapi.tiangolo.com/) + [Uvicorn](https://www.uvicorn.org/) | Async web framework + ASGI server; multipart `/predict` router |
123
+ | **Tabular ML** | [scikit-learn](https://scikit-learn.org/) (`RandomForest`, `KNNImputer`, `StandardScaler`), [XGBoost](https://xgboost.readthedocs.io/) | Model A pipeline + model comparison |
124
+ | **Image ML** | [PyTorch](https://pytorch.org/) + [torchvision](https://pytorch.org/vision/) (ResNet18) | Model B transfer learning |
125
+ | **Images / Uploads** | [Pillow](https://python-pillow.org/), `python-multipart` | ECG image decoding + file uploads |
126
+ | **Data** | [pandas](https://pandas.pydata.org/), [NumPy](https://numpy.org/) | Data handling |
127
+ | **Frontend** | HTML5, CSS3, Vanilla JS | Single-page glassmorphism UI with ECG drag-and-drop |
128
+ | **Deployment** | Docker β†’ Hugging Face Spaces | Containerized serving on port 7860 |
129
+
130
+ ---
131
+
132
+ ## πŸ“ Project Structure
133
+
134
+ ```
135
+ heart-attack-risk-predictor/
136
+ β”‚
137
+ β”œβ”€β”€ app.py # FastAPI app + ensemble router (multipart /predict)
138
+ β”œβ”€β”€ requirements.txt # Production dependencies
139
+ β”œβ”€β”€ Dockerfile # Container build (copies app, inference/, models/, static/)
140
+ β”‚
141
+ β”œβ”€β”€ inference/ # Serving-time prediction package
142
+ β”‚ β”œβ”€β”€ fusion.py # Risk bands + late-fusion (combine)
143
+ β”‚ β”œβ”€β”€ framingham.py # Model A inference (with KNN imputation)
144
+ β”‚ β”œβ”€β”€ ecg.py # Model B inference (softmax β†’ risk scalar)
145
+ β”‚ └── validation.py # Server-side field validation
146
+ β”‚
147
+ β”œβ”€β”€ models/ # Trained artifacts (Git LFS)
148
+ β”‚ β”œβ”€β”€ framingham_pipeline.joblib # Model A bundle
149
+ β”‚ β”œβ”€β”€ ecg_resnet.pt # Model B weights
150
+ β”‚ └── ecg_classes.json # ECG class list + risk weights + preprocessing
151
+ β”‚
152
+ β”œβ”€β”€ train_framingham.py # Trains Model A
153
+ β”œβ”€β”€ train_ecg.py # Trains Model B
154
+ β”‚
155
+ β”œβ”€β”€ static/index.html # Frontend (form + ECG upload + per-branch results)
156
+ β”‚
157
+ β”œβ”€β”€ DOCUMENTATION.md # Full technical documentation
158
+ β”œβ”€β”€ DEFENSE_GUIDE.md # Beginner-friendly project defense guide
159
+ β”‚
160
+ └── research_and_experiments/ # v1 biomarker research (notebook, dataset, old model)
161
+ ```
162
+
163
+ ---
164
+
165
+ ## πŸš€ Getting Started
166
+
167
+ ### Prerequisites
168
+ - Python **3.11+** (3.12 recommended)
169
+
170
+ ### Install
171
+ ```bash
172
+ git clone https://github.com/Shahd1Sayed/heart-attack-risk-predictor.git
173
+ cd heart-attack-risk-predictor
174
+ pip install -r requirements.txt
175
+ ```
176
+
177
+ ### Data (only needed to (re)train β€” download from Kaggle)
178
+ ```
179
+ data/framingham.csv # "Framingham Heart Study dataset"
180
+ data/ecg_data/<class>/*.jpg # "ECG Images Dataset of Cardiac Patients"
181
+ ```
182
+
183
+ ### Train (produces the files in models/)
184
+ ```bash
185
+ python train_framingham.py
186
+ python train_ecg.py
187
+ ```
188
+
189
+ ### Run
190
+ ```bash
191
+ uvicorn app:app --host 127.0.0.1 --port 8000
192
+ # open http://127.0.0.1:8000
193
+ ```
194
+ The app boots even before models are trained; a branch needing an untrained model
195
+ returns HTTP 503 with a hint.
196
+
197
+ ---
198
+
199
+ ## ▢️ Usage & API
200
+
201
+ `POST /predict` accepts **multipart/form-data** with optional tabular fields and an
202
+ optional `ecg` image file.
203
+
204
+ ```bash
205
+ # tabular only
206
+ curl -F age=61 -F male=1 -F sysBP=150 -F totChol=240 http://127.0.0.1:8000/predict
207
+
208
+ # ECG only
209
+ curl -F ecg=@some_ecg.jpg http://127.0.0.1:8000/predict
210
+
211
+ # both (multimodal)
212
+ curl -F age=61 -F sysBP=150 -F ecg=@some_ecg.jpg http://127.0.0.1:8000/predict
213
+ ```
214
+
215
+ **Tabular fields:** `male, age, education, currentSmoker, cigsPerDay, BPMeds,
216
+ prevalentStroke, prevalentHyp, diabetes, totChol, sysBP, diaBP, BMI, heartRate,
217
+ glucose` (all optional; blanks are KNN-imputed).
218
+
219
+ **Example response (multimodal):**
220
+ ```json
221
+ {
222
+ "mode": "multimodal",
223
+ "risk_level": "High",
224
+ "p_risk": 0.7306,
225
+ "branches": {
226
+ "tabular": { "p_risk": 0.4825, "band": "Moderate",
227
+ "detail": {"CHD": 0.4825, "No CHD": 0.5175}, "imputed_fields": ["glucose"] },
228
+ "ecg": { "p_risk": 0.9787, "band": "High", "ecg_class": "myocardial_infarction_ecg_images",
229
+ "confidence": 0.9587, "low_confidence": false, "warning": null }
230
+ }
231
+ }
232
+ ```
233
+
234
+ **Status codes:** `200` success Β· `400` no input / unreadable image Β· `422` invalid
235
+ tabular field Β· `503` model not trained yet.
236
+
237
+ ---
238
+
239
+ ## πŸ“Š Model Performance
240
+
241
+ | Model | Metric | Value |
242
+ |---|---|---|
243
+ | **Model A β€” Framingham** | CV ROC-AUC | **0.69** |
244
+ | | Hold-out ROC-AUC | 0.64 |
245
+ | | Accuracy | 0.85 *(β‰ˆ all-negative baseline β€” see note)* |
246
+ | **Model B β€” ECG ResNet18** | Test accuracy | **0.90** |
247
+ | | Macro F1 | 0.90 |
248
+ | | MI precision | 1.00 |
249
+
250
+ > ⚠️ **Metric honesty:** Framingham is imbalanced (~15% positive), so 85% accuracy is
251
+ > essentially the "always predict no-CHD" baseline β€” which is why we lead with **ROC-AUC**.
252
+ > Predicting a decade ahead from basic clinical features is genuinely hard, so ~0.64–0.69
253
+ > AUC is expected. The ECG numbers are strong for a small dataset but optimistic vs.
254
+ > other acquisition setups (the images are photos of printed ECGs).
255
+
256
+ ---
257
+
258
+ ## πŸ§ͺ Testing
259
+
260
+ The project ships with a test harness (router paths, response invariants, adversarial
261
+ inputs, determinism, an ECG serving sweep, input validation, ECG confidence, and
262
+ concurrency). **Result: 62/62 checks pass.** See `DOCUMENTATION.md` for details.
263
+
264
+ ---
265
+
266
+ ## βš–οΈ Limitations & Honesty
267
+
268
+ - **The two models predict different things** β€” Model A estimates 10-year *prognosis*;
269
+ Model B classifies the *current* ECG. They are trained on **different, unpaired
270
+ populations**, so the combined score is a **transparent heuristic demonstrating the
271
+ architecture, not a validated clinical measure**.
272
+ - **The fusion weights are hand-set** (equal average) because no paired dataset exists
273
+ to learn/validate them. A learned fusion on paired data (e.g. PTB-XL) is future work.
274
+ - **ECG images are photos of printouts** β€” a small, imbalanced dataset; the CNN may not
275
+ generalize to other setups.
276
+ - **No out-of-distribution rejection** β€” a non-ECG image is still classified (now
277
+ flagged low-confidence, but not refused).
278
+ - **Not for clinical use.**
279
+
280
+ ---
281
+
282
+ ## πŸ‘₯ Team Members
283
+
284
+ | Name | Role | GitHub |
285
+ |---|---|---|
286
+ | **Shahd Sayed** | Machine Learning Engineer | [@Shahd1Sayed](https://github.com/Shahd1Sayed) |
287
+ | **Shahd Mohammed** | Full-Stack Developer | |
288
+
289
+ ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,82 +1,134 @@
1
- import pickle
2
- import numpy as np
3
- from pathlib import Path
4
- from fastapi import FastAPI
5
- from fastapi.staticfiles import StaticFiles
6
- from fastapi.responses import FileResponse
7
- from pydantic import BaseModel
8
-
9
- # ── Load model bundle ────────────────────────────────────────────────────────
10
- MODEL_PATH = Path(__file__).parent / "model.pkl"
11
-
12
- with open(MODEL_PATH, "rb") as f:
13
- bundle = pickle.load(f)
14
-
15
- model = bundle["model"]
16
- scaler = bundle["scaler"]
17
- le_gender = bundle["le_gender"]
18
- le_target = bundle["le_target"]
19
-
20
- # ── FastAPI app ──────────────────────────────────────────────────────────────
21
- app = FastAPI(title="Heart Attack Risk Predictor")
22
-
23
-
24
- # ── Request schema ───────────────────────────────────────────────────────────
25
- class PatientVitals(BaseModel):
26
- age: int
27
- gender: str # "Male" or "Female"
28
- heart_rate: int
29
- systolic_bp: int
30
- diastolic_bp: int
31
- blood_sugar: int
32
- ck_mb: float
33
- troponin: float
34
-
35
-
36
- # ── POST /predict ────────────────────────────────────────────────────────────
37
- @app.post("/predict")
38
- def predict(vitals: PatientVitals):
39
- # Encode gender: the dataset uses 0 = Female, 1 = Male
40
- gender_encoded = le_gender.transform([int(vitals.gender)])[0] \
41
- if vitals.gender.isdigit() \
42
- else (1 if vitals.gender.strip().lower() == "male" else 0)
43
-
44
- features = np.array([[
45
- vitals.age,
46
- gender_encoded,
47
- vitals.heart_rate,
48
- vitals.systolic_bp,
49
- vitals.diastolic_bp,
50
- vitals.blood_sugar,
51
- vitals.ck_mb,
52
- vitals.troponin,
53
- ]])
54
-
55
- features_scaled = scaler.transform(features)
56
-
57
- prediction = model.predict(features_scaled)[0]
58
- probabilities = model.predict_proba(features_scaled)[0]
59
- risk_label = le_target.inverse_transform([prediction])[0]
60
-
61
- # Map probabilities to class names
62
- class_probs = {
63
- le_target.inverse_transform([i])[0]: round(float(p), 4)
64
- for i, p in enumerate(probabilities)
65
- }
66
-
67
- return {
68
- "risk_level": risk_label,
69
- "probabilities": class_probs,
70
- }
71
-
72
-
73
- # ── Serve frontend ──────────────────────────────────────────────────────────
74
- STATIC_DIR = Path(__file__).parent / "static"
75
- STATIC_DIR.mkdir(exist_ok=True)
76
-
77
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
78
-
79
-
80
- @app.get("/")
81
- def serve_frontend():
82
- return FileResponse(str(STATIC_DIR / "index.html"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal heart-attack risk app β€” ensemble router.
2
+
3
+ One ``POST /predict`` endpoint accepts multipart/form-data carrying *optional*
4
+ Framingham tabular fields and an *optional* ECG image. An internal router picks
5
+ the path:
6
+
7
+ tabular only -> Model A (Framingham CHD)
8
+ ECG only -> Model B (ResNet ECG)
9
+ both -> A + B, averaged
10
+ neither -> HTTP 400
11
+
12
+ The predictor modules are imported lazily so the app still boots (and serves the
13
+ frontend) before the models have been trained.
14
+
15
+ Run: uvicorn app:app --reload
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
23
+ from fastapi.concurrency import run_in_threadpool
24
+ from fastapi.responses import FileResponse
25
+ from fastapi.staticfiles import StaticFiles
26
+
27
+ from inference.fusion import band_for, combine
28
+ from inference.validation import validate_tabular
29
+
30
+ # Canonical Framingham feature order (mirrors train_framingham.FEATURES).
31
+ FEATURES = [
32
+ "male", "age", "education", "currentSmoker", "cigsPerDay", "BPMeds",
33
+ "prevalentStroke", "prevalentHyp", "diabetes", "totChol", "sysBP",
34
+ "diaBP", "BMI", "heartRate", "glucose",
35
+ ]
36
+
37
+ app = FastAPI(title="Multimodal Heart Attack Risk β€” Ensemble Router")
38
+
39
+
40
+ def _has_value(v: Optional[str]) -> bool:
41
+ return v is not None and str(v).strip() != ""
42
+
43
+
44
+ @app.post("/predict")
45
+ async def predict(
46
+ # ── Tabular branch (all optional β†’ blanks are KNN-imputed) ───────────────
47
+ male: Optional[str] = Form(None),
48
+ age: Optional[str] = Form(None),
49
+ education: Optional[str] = Form(None),
50
+ currentSmoker: Optional[str] = Form(None),
51
+ cigsPerDay: Optional[str] = Form(None),
52
+ BPMeds: Optional[str] = Form(None),
53
+ prevalentStroke: Optional[str] = Form(None),
54
+ prevalentHyp: Optional[str] = Form(None),
55
+ diabetes: Optional[str] = Form(None),
56
+ totChol: Optional[str] = Form(None),
57
+ sysBP: Optional[str] = Form(None),
58
+ diaBP: Optional[str] = Form(None),
59
+ BMI: Optional[str] = Form(None),
60
+ heartRate: Optional[str] = Form(None),
61
+ glucose: Optional[str] = Form(None),
62
+ # ── Image branch (optional) ──────────────────────────────────────────────
63
+ ecg: Optional[UploadFile] = File(None),
64
+ ):
65
+ fields = {
66
+ "male": male, "age": age, "education": education,
67
+ "currentSmoker": currentSmoker, "cigsPerDay": cigsPerDay, "BPMeds": BPMeds,
68
+ "prevalentStroke": prevalentStroke, "prevalentHyp": prevalentHyp,
69
+ "diabetes": diabetes, "totChol": totChol, "sysBP": sysBP, "diaBP": diaBP,
70
+ "BMI": BMI, "heartRate": heartRate, "glucose": glucose,
71
+ }
72
+
73
+ has_tabular = any(_has_value(fields[f]) for f in FEATURES)
74
+ has_ecg = ecg is not None and bool(ecg.filename)
75
+
76
+ if not has_tabular and not has_ecg:
77
+ raise HTTPException(
78
+ status_code=400,
79
+ detail="Provide tabular patient data, an ECG image, or both.",
80
+ )
81
+
82
+ # Validate any provided tabular values (blanks are skipped β†’ KNN-imputed).
83
+ if has_tabular:
84
+ errors = validate_tabular(fields)
85
+ if errors:
86
+ raise HTTPException(status_code=422, detail="; ".join(errors))
87
+
88
+ branches: dict = {}
89
+
90
+ # CPU-bound inference is offloaded to a threadpool so concurrent requests
91
+ # don't block the event loop (torch/sklearn release the GIL).
92
+ if has_tabular:
93
+ try:
94
+ from inference.framingham import predict_tabular
95
+ branches["tabular"] = await run_in_threadpool(predict_tabular, fields)
96
+ except FileNotFoundError as exc:
97
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
98
+
99
+ if has_ecg:
100
+ image_bytes = await ecg.read()
101
+ try:
102
+ from inference.ecg import predict_ecg
103
+ branches["ecg"] = await run_in_threadpool(predict_ecg, image_bytes)
104
+ except FileNotFoundError as exc:
105
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
106
+ except ValueError as exc:
107
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
108
+
109
+ # ── Fuse / select headline ───────────────────────────────────────────────
110
+ if has_tabular and has_ecg:
111
+ p = combine(branches["tabular"]["p_risk"], branches["ecg"]["p_risk"])
112
+ mode, p_head = "multimodal", p
113
+ elif has_tabular:
114
+ mode, p_head = "tabular", branches["tabular"]["p_risk"]
115
+ else:
116
+ mode, p_head = "ecg", branches["ecg"]["p_risk"]
117
+
118
+ return {
119
+ "mode": mode,
120
+ "risk_level": band_for(p_head),
121
+ "p_risk": round(p_head, 4),
122
+ "branches": branches,
123
+ }
124
+
125
+
126
+ # ── Serve frontend ───────────────────────────────────────────────────────────
127
+ STATIC_DIR = Path(__file__).parent / "static"
128
+ STATIC_DIR.mkdir(exist_ok=True)
129
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
130
+
131
+
132
+ @app.get("/")
133
+ def serve_frontend():
134
+ return FileResponse(str(STATIC_DIR / "index.html"))
inference/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Inference package: per-modality predictors and the late-fusion router logic."""
inference/ecg.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image branch (Model B) inference: ECG ResNet classifier.
2
+
3
+ Loads the fine-tuned ResNet once and exposes ``predict_ecg``. The 4-class
4
+ softmax is collapsed into a single risk scalar via the per-class risk weights
5
+ saved at train time (Normalβ‰ˆ0 … MIβ‰ˆ1).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ from PIL import Image
18
+ from torchvision import models, transforms
19
+
20
+ from .fusion import band_for
21
+
22
+ MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
23
+ WEIGHTS_PATH = MODELS_DIR / "ecg_resnet.pt"
24
+ CLASSES_PATH = MODELS_DIR / "ecg_classes.json"
25
+
26
+ _DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
27
+
28
+ # Pin intra-op parallelism to 1 thread per inference. The request-level
29
+ # threadpool in app.py provides concurrency; without this pin, N concurrent
30
+ # requests each spin up torch's multi-thread pools and oversubscribe the CPU
31
+ # (measured: throughput dropped and tail latency exploded under load).
32
+ torch.set_num_threads(1)
33
+
34
+ # If the top class probability is below this, the image probably isn't a clear
35
+ # 12-lead ECG (or is out-of-distribution) β€” flag it rather than trusting it.
36
+ ECG_MIN_CONFIDENCE = 0.55
37
+
38
+ _ARCH_BUILDERS = {
39
+ "resnet18": models.resnet18,
40
+ "resnet50": models.resnet50,
41
+ }
42
+
43
+
44
+ @lru_cache(maxsize=1)
45
+ def _load():
46
+ if not WEIGHTS_PATH.exists() or not CLASSES_PATH.exists():
47
+ raise FileNotFoundError(
48
+ f"{WEIGHTS_PATH.name}/{CLASSES_PATH.name} not found in {MODELS_DIR} β€” "
49
+ f"run `python train_ecg.py` first."
50
+ )
51
+ meta = json.loads(CLASSES_PATH.read_text())
52
+ builder = _ARCH_BUILDERS.get(meta.get("arch", "resnet18"), models.resnet18)
53
+ model = builder(weights=None)
54
+ model.fc = nn.Linear(model.fc.in_features, len(meta["classes"]))
55
+ model.load_state_dict(torch.load(WEIGHTS_PATH, map_location=_DEVICE))
56
+ model.eval().to(_DEVICE)
57
+
58
+ tf = transforms.Compose([
59
+ transforms.Resize((meta["img_size"], meta["img_size"])),
60
+ transforms.ToTensor(),
61
+ transforms.Normalize(meta["mean"], meta["std"]),
62
+ ])
63
+ risk_weights = torch.tensor(meta["risk_weights"], dtype=torch.float32)
64
+ return model, tf, meta["classes"], risk_weights
65
+
66
+
67
+ def predict_ecg(image_bytes: bytes) -> dict[str, Any]:
68
+ """Predict ECG risk from raw image bytes."""
69
+ model, tf, classes, risk_weights = _load()
70
+
71
+ try:
72
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
73
+ except Exception as exc: # noqa: BLE001
74
+ raise ValueError(f"Could not read image: {exc}") from exc
75
+
76
+ x = tf(img).unsqueeze(0).to(_DEVICE)
77
+ with torch.no_grad():
78
+ probs = torch.softmax(model(x), dim=1)[0].cpu()
79
+
80
+ p_risk = float((probs * risk_weights).sum())
81
+ pred_idx = int(probs.argmax())
82
+ confidence = float(probs[pred_idx])
83
+ low_confidence = confidence < ECG_MIN_CONFIDENCE
84
+
85
+ return {
86
+ "p_risk": p_risk,
87
+ "band": band_for(p_risk),
88
+ "ecg_class": classes[pred_idx],
89
+ "confidence": round(confidence, 4),
90
+ "low_confidence": low_confidence,
91
+ "warning": (
92
+ "Low-confidence ECG classification β€” the image may not be a clear "
93
+ "12-lead ECG. Interpret with caution."
94
+ if low_confidence else None
95
+ ),
96
+ "detail": {c: round(float(p), 4) for c, p in zip(classes, probs)},
97
+ }
inference/framingham.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tabular branch (Model A) inference: Framingham 10-year CHD risk.
2
+
3
+ Loads the fitted pipeline once and exposes ``predict_tabular``. Any field the
4
+ caller leaves out (or sends as ``None``/blank) is passed to the pipeline as NaN
5
+ and filled by the fitted ``KNNImputer`` β€” i.e. the user can submit a partial
6
+ form and the model still runs.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import joblib
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from .fusion import band_for
19
+
20
+ BUNDLE_PATH = Path(__file__).resolve().parent.parent / "models" / "framingham_pipeline.joblib"
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _load() -> dict[str, Any]:
25
+ if not BUNDLE_PATH.exists():
26
+ raise FileNotFoundError(
27
+ f"{BUNDLE_PATH} not found β€” run `python train_framingham.py` first."
28
+ )
29
+ return joblib.load(BUNDLE_PATH)
30
+
31
+
32
+ def feature_names() -> list[str]:
33
+ return list(_load()["features"])
34
+
35
+
36
+ def _to_float(value: Any) -> float:
37
+ """Coerce a form value to float; blanks / None / '' become NaN (to impute)."""
38
+ if value is None:
39
+ return np.nan
40
+ if isinstance(value, str) and value.strip() == "":
41
+ return np.nan
42
+ try:
43
+ return float(value)
44
+ except (TypeError, ValueError):
45
+ return np.nan
46
+
47
+
48
+ def predict_tabular(fields: dict[str, Any]) -> dict[str, Any]:
49
+ """Predict CHD risk from a (possibly partial) dict of Framingham fields."""
50
+ bundle = _load()
51
+ pipeline = bundle["pipeline"]
52
+ features: list[str] = bundle["features"]
53
+
54
+ row = {f: _to_float(fields.get(f)) for f in features}
55
+ imputed = [f for f, v in row.items() if isinstance(v, float) and np.isnan(v)]
56
+ X = pd.DataFrame([row], columns=features)
57
+
58
+ classes = list(pipeline.classes_)
59
+ pos_idx = classes.index(1) if 1 in classes else len(classes) - 1
60
+ p_risk = float(pipeline.predict_proba(X)[0][pos_idx])
61
+
62
+ return {
63
+ "p_risk": p_risk,
64
+ "band": band_for(p_risk),
65
+ "detail": {"CHD": round(p_risk, 4), "No CHD": round(1 - p_risk, 4)},
66
+ "imputed_fields": imputed,
67
+ "model_name": bundle.get("model_name", "Framingham"),
68
+ }
inference/fusion.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Risk-band thresholds and late-fusion logic shared by every router path.
2
+
3
+ Each model branch produces a single scalar risk probability ``p_risk`` in
4
+ ``[0, 1]``. We map that to a 3-level band (to match the existing UI) and, when
5
+ both modalities are present, combine the two branch scores.
6
+
7
+ IMPORTANT (documented limitation): the tabular branch (Framingham) estimates the
8
+ probability of coronary heart disease over the *next 10 years*, while the ECG
9
+ branch estimates how abnormal the *current* ECG is. These are different targets
10
+ measured on different populations, so the combined score is a transparent
11
+ heuristic, **not** a validated clinical measure.
12
+
13
+ NOTE: with only two models, the *median* of ``{p_a, p_b}`` equals their *mean*,
14
+ so "median" and "average" are identical here. ``combine`` keeps the weights
15
+ explicit so a confidence-weighting scheme can be dropped in later.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ # ── Band thresholds on p_risk ∈ [0, 1] ───────────────────────────────────────
20
+ LOW_MAX: float = 0.33
21
+ MODERATE_MAX: float = 0.66
22
+
23
+ BANDS = ("Low", "Moderate", "High")
24
+
25
+
26
+ def band_for(p: float) -> str:
27
+ """Map a risk scalar to Low / Moderate / High."""
28
+ if p < LOW_MAX:
29
+ return "Low"
30
+ if p < MODERATE_MAX:
31
+ return "Moderate"
32
+ return "High"
33
+
34
+
35
+ def combine(p_a: float, p_b: float, w_a: float = 0.5, w_b: float = 0.5) -> float:
36
+ """Late-fusion of two branch risk scores.
37
+
38
+ Defaults to an equal-weight mean (== median for two values). Weights are
39
+ normalised so callers can pass relative confidences without pre-scaling.
40
+ """
41
+ total = w_a + w_b
42
+ if total <= 0:
43
+ raise ValueError("fusion weights must sum to a positive number")
44
+ return (w_a * p_a + w_b * p_b) / total
inference/validation.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server-side range validation for the Framingham tabular fields.
2
+
3
+ Blank / missing fields are skipped here (they get KNN-imputed downstream).
4
+ Only *provided* values are checked: they must parse as numbers and fall inside
5
+ clinically plausible bounds. Returns a list of human-readable error strings
6
+ (empty == valid).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ # Binary / categorical fields use "choices"; continuous fields use min/max.
13
+ FIELD_SPECS: dict[str, dict[str, Any]] = {
14
+ "male": {"choices": {0, 1}},
15
+ "age": {"min": 1, "max": 120},
16
+ "education": {"choices": {1, 2, 3, 4}},
17
+ "currentSmoker": {"choices": {0, 1}},
18
+ "cigsPerDay": {"min": 0, "max": 100},
19
+ "BPMeds": {"choices": {0, 1}},
20
+ "prevalentStroke": {"choices": {0, 1}},
21
+ "prevalentHyp": {"choices": {0, 1}},
22
+ "diabetes": {"choices": {0, 1}},
23
+ "totChol": {"min": 50, "max": 700},
24
+ "sysBP": {"min": 50, "max": 300},
25
+ "diaBP": {"min": 30, "max": 200},
26
+ "BMI": {"min": 10, "max": 70},
27
+ "heartRate": {"min": 20, "max": 300},
28
+ "glucose": {"min": 20, "max": 700},
29
+ }
30
+
31
+
32
+ def validate_tabular(fields: dict[str, Any]) -> list[str]:
33
+ errors: list[str] = []
34
+ for name, spec in FIELD_SPECS.items():
35
+ raw = fields.get(name)
36
+ if raw is None or str(raw).strip() == "":
37
+ continue # blank -> imputed later, nothing to validate
38
+ try:
39
+ v = float(raw)
40
+ except (TypeError, ValueError):
41
+ errors.append(f"{name}: '{raw}' is not a number")
42
+ continue
43
+ if "choices" in spec and v not in spec["choices"]:
44
+ errors.append(f"{name}: must be one of {sorted(spec['choices'])} (got {raw})")
45
+ if "min" in spec and v < spec["min"]:
46
+ errors.append(f"{name}: must be >= {spec['min']} (got {raw})")
47
+ if "max" in spec and v > spec["max"]:
48
+ errors.append(f"{name}: must be <= {spec['max']} (got {raw})")
49
+ return errors
models/ecg_classes.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "arch": "resnet18",
3
+ "classes": [
4
+ "abnormal_heartbeat_ecg_images",
5
+ "myocardial_infarction_ecg_images",
6
+ "normal_ecg_images",
7
+ "post_mi_history_ecg_images"
8
+ ],
9
+ "risk_weights": [
10
+ 0.5,
11
+ 1.0,
12
+ 0.0,
13
+ 0.8
14
+ ],
15
+ "img_size": 224,
16
+ "mean": [
17
+ 0.485,
18
+ 0.456,
19
+ 0.406
20
+ ],
21
+ "std": [
22
+ 0.229,
23
+ 0.224,
24
+ 0.225
25
+ ]
26
+ }
models/ecg_resnet.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a790e17387fa23a76dd1493335da7157f68dd1efec0c601405836484986db99e
3
+ size 44792139
models/framingham_pipeline.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:19b04e7503707b319bbced0fb2c0bbbc191ccc6672eb1f2c41c90672d0ad13de
3
+ size 45325835
requirements.txt CHANGED
@@ -1,5 +1,18 @@
1
- fastapi
2
- uvicorn
3
- scikit-learn
4
- numpy
5
- pydantic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ # ── Web app (FastAPI, not Flask) ─────────────────────────────────────────────
3
+ fastapi
4
+ uvicorn[standard]
5
+ python-multipart # required for multipart/form-data file uploads
6
+ pydantic
7
+
8
+ # ── Model A β€” Framingham tabular (KNN-impute β†’ scale β†’ RF/XGB) ────────────────
9
+ scikit-learn
10
+ xgboost
11
+ pandas
12
+ numpy
13
+ joblib
14
+
15
+ # ── Model B β€” ECG image CNN (ResNet transfer learning) ───────────────────────
16
+ torch
17
+ torchvision
18
+ pillow
static/index.html CHANGED
@@ -1,429 +1,542 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Heart Attack Risk Predictor</title>
7
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
8
- <style>
9
- /* ── Reset & Base ─────────────────────────────────────────── */
10
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
-
12
- :root {
13
- --bg: #0b0f1a;
14
- --surface: rgba(255,255,255,0.04);
15
- --border: rgba(255,255,255,0.08);
16
- --text: #e2e8f0;
17
- --text-dim: #8892a4;
18
- --accent: #6366f1;
19
- --accent-g: linear-gradient(135deg, #6366f1, #ec4899);
20
- --high: #ef4444;
21
- --moderate: #f59e0b;
22
- --low: #22c55e;
23
- --radius: 14px;
24
- }
25
-
26
- body {
27
- font-family: 'Inter', sans-serif;
28
- background: var(--bg);
29
- color: var(--text);
30
- min-height: 100vh;
31
- display: flex;
32
- align-items: center;
33
- justify-content: center;
34
- padding: 2rem 1rem;
35
- overflow-x: hidden;
36
- }
37
-
38
- /* Animated gradient blob */
39
- body::before {
40
- content: '';
41
- position: fixed;
42
- top: -40%; left: -10%;
43
- width: 60vw; height: 60vw;
44
- background: radial-gradient(circle, rgba(99,102,241,0.18), transparent 70%);
45
- filter: blur(80px);
46
- animation: float 8s ease-in-out infinite alternate;
47
- pointer-events: none;
48
- z-index: 0;
49
- }
50
- body::after {
51
- content: '';
52
- position: fixed;
53
- bottom: -30%; right: -10%;
54
- width: 50vw; height: 50vw;
55
- background: radial-gradient(circle, rgba(236,72,153,0.14), transparent 70%);
56
- filter: blur(80px);
57
- animation: float 10s ease-in-out infinite alternate-reverse;
58
- pointer-events: none;
59
- z-index: 0;
60
- }
61
- @keyframes float { to { transform: translate(40px, -30px) scale(1.05); } }
62
-
63
- /* ── Card ─────────────────────────────────────────────────── */
64
- .card {
65
- position: relative; z-index: 1;
66
- width: 100%; max-width: 520px;
67
- background: var(--surface);
68
- backdrop-filter: blur(24px) saturate(140%);
69
- -webkit-backdrop-filter: blur(24px) saturate(140%);
70
- border: 1px solid var(--border);
71
- border-radius: var(--radius);
72
- padding: 2.5rem 2rem 2rem;
73
- }
74
-
75
- /* ── Header ───────────────────────────────────────────────── */
76
- .header { text-align: center; margin-bottom: 2rem; }
77
- .header .icon {
78
- width: 56px; height: 56px;
79
- margin: 0 auto .75rem;
80
- background: var(--accent-g);
81
- border-radius: 16px;
82
- display: grid; place-items: center;
83
- font-size: 28px;
84
- box-shadow: 0 4px 20px rgba(99,102,241,0.35);
85
- }
86
- .header h1 {
87
- font-size: 1.35rem; font-weight: 700;
88
- background: var(--accent-g);
89
- -webkit-background-clip: text;
90
- -webkit-text-fill-color: transparent;
91
- }
92
- .header p { font-size: .82rem; color: var(--text-dim); margin-top: .3rem; }
93
-
94
- /* ── Form grid ────────────────────────────────────────────── */
95
- .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .85rem; }
96
-
97
- .field { display: flex; flex-direction: column; }
98
- .field.full { grid-column: 1 / -1; }
99
-
100
- label {
101
- font-size: .72rem; font-weight: 500;
102
- color: var(--text-dim);
103
- text-transform: uppercase;
104
- letter-spacing: .06em;
105
- margin-bottom: .3rem;
106
- }
107
-
108
- input, select {
109
- background: rgba(255,255,255,0.05);
110
- border: 1px solid var(--border);
111
- border-radius: 10px;
112
- padding: .65rem .85rem;
113
- color: var(--text);
114
- font-family: inherit;
115
- font-size: .88rem;
116
- outline: none;
117
- transition: border-color .2s, box-shadow .2s;
118
- }
119
- input:focus, select:focus {
120
- border-color: var(--accent);
121
- box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
122
- }
123
- input::placeholder { color: rgba(255,255,255,0.2); }
124
- select option { background: #1e1e2e; color: var(--text); }
125
-
126
- /* ── Button ───────────────────────────────────────────────── */
127
- .btn {
128
- grid-column: 1 / -1;
129
- margin-top: .5rem;
130
- padding: .8rem;
131
- border: none;
132
- border-radius: 10px;
133
- background: var(--accent-g);
134
- color: #fff;
135
- font-family: inherit;
136
- font-size: .92rem;
137
- font-weight: 600;
138
- cursor: pointer;
139
- transition: transform .15s, box-shadow .25s;
140
- box-shadow: 0 4px 20px rgba(99,102,241,0.3);
141
- }
142
- .btn:hover { transform: translateY(-1px); box-shadow: 0 6px 28px rgba(99,102,241,0.45); }
143
- .btn:active { transform: scale(.98); }
144
- .btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
145
-
146
- /* ── Spinner ──────────────────────────────────────────────── */
147
- .spinner {
148
- display: none;
149
- width: 18px; height: 18px;
150
- border: 2px solid rgba(255,255,255,.3);
151
- border-top-color: #fff;
152
- border-radius: 50%;
153
- animation: spin .6s linear infinite;
154
- margin: 0 auto;
155
- }
156
- .btn.loading .spinner { display: inline-block; }
157
- .btn.loading .btn-text { display: none; }
158
- @keyframes spin { to { transform: rotate(360deg); } }
159
-
160
- /* ── Result panel ─────────────────────────────────────────── */
161
- .result {
162
- margin-top: 1.5rem;
163
- border-radius: 12px;
164
- overflow: hidden;
165
- opacity: 0;
166
- transform: translateY(10px);
167
- transition: opacity .4s, transform .4s;
168
- }
169
- .result.show { opacity: 1; transform: translateY(0); }
170
-
171
- .result-header {
172
- padding: 1rem 1.25rem;
173
- display: flex;
174
- align-items: center;
175
- gap: .75rem;
176
- }
177
- .result-header .badge {
178
- font-size: .7rem;
179
- font-weight: 600;
180
- text-transform: uppercase;
181
- letter-spacing: .08em;
182
- padding: .25rem .65rem;
183
- border-radius: 6px;
184
- }
185
- .result-header .risk-text {
186
- font-size: 1.15rem;
187
- font-weight: 700;
188
- }
189
-
190
- .result.high .result-header { background: rgba(239,68,68,0.12); }
191
- .result.high .badge { background: var(--high); color: #fff; }
192
- .result.high .risk-text { color: var(--high); }
193
-
194
- .result.moderate .result-header { background: rgba(245,158,11,0.12); }
195
- .result.moderate .badge { background: var(--moderate); color: #1a1a1a; }
196
- .result.moderate .risk-text { color: var(--moderate); }
197
-
198
- .result.low .result-header { background: rgba(34,197,94,0.12); }
199
- .result.low .badge { background: var(--low); color: #1a1a1a; }
200
- .result.low .risk-text { color: var(--low); }
201
-
202
- /* Probability bars */
203
- .prob-bars { padding: .75rem 1.25rem 1rem; }
204
- .prob-row {
205
- display: flex; align-items: center; gap: .6rem;
206
- margin-bottom: .5rem;
207
- }
208
- .prob-row:last-child { margin-bottom: 0; }
209
- .prob-label {
210
- width: 70px;
211
- font-size: .72rem;
212
- font-weight: 500;
213
- color: var(--text-dim);
214
- }
215
- .prob-track {
216
- flex: 1;
217
- height: 8px;
218
- background: rgba(255,255,255,0.06);
219
- border-radius: 4px;
220
- overflow: hidden;
221
- }
222
- .prob-fill {
223
- height: 100%;
224
- border-radius: 4px;
225
- transition: width .6s cubic-bezier(.4,0,.2,1);
226
- }
227
- .prob-fill.high { background: var(--high); }
228
- .prob-fill.moderate { background: var(--moderate); }
229
- .prob-fill.low { background: var(--low); }
230
- .prob-value {
231
- width: 44px;
232
- text-align: right;
233
- font-size: .75rem;
234
- font-weight: 600;
235
- color: var(--text);
236
- }
237
-
238
- /* ── Error ────────────────────────────────────────────────── */
239
- .error-msg {
240
- margin-top: 1rem;
241
- padding: .75rem 1rem;
242
- background: rgba(239,68,68,0.1);
243
- border: 1px solid rgba(239,68,68,0.25);
244
- border-radius: 10px;
245
- color: var(--high);
246
- font-size: .82rem;
247
- display: none;
248
- }
249
- .error-msg.show { display: block; }
250
-
251
- /* ── Responsive ───────────────────────────────────────────── */
252
- @media (max-width: 480px) {
253
- .form-grid { grid-template-columns: 1fr; }
254
- .card { padding: 2rem 1.25rem 1.5rem; }
255
- }
256
- </style>
257
- </head>
258
- <body>
259
- <div class="card">
260
- <!-- Header -->
261
- <div class="header">
262
- <div class="icon">&#x1F9E0;</div>
263
- <h1>Heart Attack Risk Predictor</h1>
264
- <p>Enter patient vitals to assess cardiac risk level</p>
265
- </div>
266
-
267
- <!-- Form -->
268
- <form id="predictForm" class="form-grid" autocomplete="off">
269
-
270
- <div class="field">
271
- <label for="age">Age</label>
272
- <input id="age" type="number" placeholder="e.g. 55" min="1" max="120" required />
273
- </div>
274
-
275
- <div class="field">
276
- <label for="gender">Gender</label>
277
- <select id="gender" required>
278
- <option value="" disabled selected>Select</option>
279
- <option value="Male">Male</option>
280
- <option value="Female">Female</option>
281
- </select>
282
- </div>
283
-
284
- <div class="field">
285
- <label for="heart_rate">Heart Rate (bpm)</label>
286
- <input id="heart_rate" type="number" placeholder="e.g. 72" min="20" max="300" required />
287
- </div>
288
-
289
- <div class="field">
290
- <label for="systolic_bp">Systolic BP (mmHg)</label>
291
- <input id="systolic_bp" type="number" placeholder="e.g. 120" min="50" max="300" required />
292
- </div>
293
-
294
- <div class="field">
295
- <label for="diastolic_bp">Diastolic BP (mmHg)</label>
296
- <input id="diastolic_bp" type="number" placeholder="e.g. 80" min="30" max="200" required />
297
- </div>
298
-
299
- <div class="field">
300
- <label for="blood_sugar">Blood Sugar (mg/dL)</label>
301
- <input id="blood_sugar" type="number" placeholder="e.g. 100" min="30" max="700" required />
302
- </div>
303
-
304
- <div class="field">
305
- <label for="ck_mb">CK-MB (ng/mL)</label>
306
- <input id="ck_mb" type="number" step="any" placeholder="e.g. 2.5" min="0" required />
307
- </div>
308
-
309
- <div class="field">
310
- <label for="troponin">Troponin (ng/mL)</label>
311
- <input id="troponin" type="number" step="any" placeholder="e.g. 0.01" min="0" required />
312
- </div>
313
-
314
- <button type="submit" class="btn" id="submitBtn">
315
- <span class="btn-text">Predict Risk Level</span>
316
- <span class="spinner"></span>
317
- </button>
318
- </form>
319
-
320
- <!-- Error -->
321
- <div class="error-msg" id="errorMsg"></div>
322
-
323
- <!-- Result -->
324
- <div class="result" id="resultPanel">
325
- <div class="result-header">
326
- <span class="badge" id="badge"></span>
327
- <span class="risk-text" id="riskText"></span>
328
- </div>
329
- <div class="prob-bars" id="probBars"></div>
330
- </div>
331
- </div>
332
-
333
- <script>
334
- const form = document.getElementById('predictForm');
335
- const btn = document.getElementById('submitBtn');
336
- const errDiv = document.getElementById('errorMsg');
337
- const result = document.getElementById('resultPanel');
338
- const badge = document.getElementById('badge');
339
- const riskText = document.getElementById('riskText');
340
- const probBars = document.getElementById('probBars');
341
-
342
- const riskMessages = {
343
- High: 'Immediate medical attention recommended',
344
- Moderate: 'Monitor closely and consult a doctor',
345
- Low: 'Maintain a healthy lifestyle',
346
- };
347
-
348
- form.addEventListener('submit', async (e) => {
349
- e.preventDefault();
350
- errDiv.classList.remove('show');
351
- result.classList.remove('show', 'high', 'moderate', 'low');
352
-
353
- const payload = {
354
- age: +document.getElementById('age').value,
355
- gender: document.getElementById('gender').value,
356
- heart_rate: +document.getElementById('heart_rate').value,
357
- systolic_bp: +document.getElementById('systolic_bp').value,
358
- diastolic_bp: +document.getElementById('diastolic_bp').value,
359
- blood_sugar: +document.getElementById('blood_sugar').value,
360
- ck_mb: +document.getElementById('ck_mb').value,
361
- troponin: +document.getElementById('troponin').value,
362
- };
363
-
364
- btn.classList.add('loading');
365
- btn.disabled = true;
366
-
367
- try {
368
- const res = await fetch('/predict', {
369
- method: 'POST',
370
- headers: { 'Content-Type': 'application/json' },
371
- body: JSON.stringify(payload),
372
- });
373
-
374
- if (!res.ok) {
375
- const err = await res.json();
376
- throw new Error(err.detail || 'Server error');
377
- }
378
-
379
- const data = await res.json();
380
- showResult(data);
381
- } catch (err) {
382
- errDiv.textContent = err.message || 'Failed to reach the server.';
383
- errDiv.classList.add('show');
384
- } finally {
385
- btn.classList.remove('loading');
386
- btn.disabled = false;
387
- }
388
- });
389
-
390
- function showResult(data) {
391
- const level = data.risk_level; // "High" | "Moderate" | "Low"
392
- const cls = level.toLowerCase();
393
-
394
- result.className = 'result ' + cls;
395
- badge.textContent = level + ' risk';
396
- riskText.textContent = riskMessages[level] || level;
397
-
398
- // Build probability bars
399
- probBars.innerHTML = '';
400
- const order = ['High', 'Moderate', 'Low'];
401
-
402
- for (const key of order) {
403
- const pct = ((data.probabilities[key] || 0) * 100).toFixed(1);
404
- const row = document.createElement('div');
405
- row.className = 'prob-row';
406
- row.innerHTML = `
407
- <span class="prob-label">${key}</span>
408
- <div class="prob-track">
409
- <div class="prob-fill ${key.toLowerCase()}" style="width:0%"></div>
410
- </div>
411
- <span class="prob-value">${pct}%</span>
412
- `;
413
- probBars.appendChild(row);
414
- }
415
-
416
- // Animate
417
- requestAnimationFrame(() => {
418
- result.classList.add('show');
419
- setTimeout(() => {
420
- probBars.querySelectorAll('.prob-fill').forEach((bar, i) => {
421
- const pct = ((data.probabilities[order[i]] || 0) * 100).toFixed(1);
422
- bar.style.width = pct + '%';
423
- });
424
- }, 100);
425
- });
426
- }
427
- </script>
428
- </body>
429
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Multimodal Heart Attack Risk</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ :root {
12
+ --bg: #0b0f1a;
13
+ --surface: rgba(255,255,255,0.04);
14
+ --surface-2: rgba(255,255,255,0.06);
15
+ --border: rgba(255,255,255,0.08);
16
+ --text: #e2e8f0;
17
+ --text-dim: #8892a4;
18
+ --accent: #6366f1;
19
+ --accent-g: linear-gradient(135deg, #6366f1, #ec4899);
20
+ --high: #ef4444;
21
+ --moderate: #f59e0b;
22
+ --low: #22c55e;
23
+ --radius: 14px;
24
+ }
25
+
26
+ body {
27
+ font-family: 'Inter', sans-serif;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ min-height: 100vh;
31
+ display: flex;
32
+ align-items: flex-start;
33
+ justify-content: center;
34
+ padding: 2.5rem 1rem;
35
+ overflow-x: hidden;
36
+ }
37
+
38
+ body::before, body::after {
39
+ content: ''; position: fixed; pointer-events: none; z-index: 0;
40
+ filter: blur(80px);
41
+ }
42
+ body::before {
43
+ top: -40%; left: -10%; width: 60vw; height: 60vw;
44
+ background: radial-gradient(circle, rgba(99,102,241,0.18), transparent 70%);
45
+ animation: float 8s ease-in-out infinite alternate;
46
+ }
47
+ body::after {
48
+ bottom: -30%; right: -10%; width: 50vw; height: 50vw;
49
+ background: radial-gradient(circle, rgba(236,72,153,0.14), transparent 70%);
50
+ animation: float 10s ease-in-out infinite alternate-reverse;
51
+ }
52
+ @keyframes float { to { transform: translate(40px, -30px) scale(1.05); } }
53
+
54
+ .card {
55
+ position: relative; z-index: 1;
56
+ width: 100%; max-width: 600px;
57
+ background: var(--surface);
58
+ backdrop-filter: blur(24px) saturate(140%);
59
+ -webkit-backdrop-filter: blur(24px) saturate(140%);
60
+ border: 1px solid var(--border);
61
+ border-radius: var(--radius);
62
+ padding: 2.5rem 2rem 2rem;
63
+ }
64
+
65
+ .header { text-align: center; margin-bottom: 1.75rem; }
66
+ .header .icon {
67
+ width: 56px; height: 56px; margin: 0 auto .75rem;
68
+ background: var(--accent-g); border-radius: 16px;
69
+ display: grid; place-items: center; font-size: 28px;
70
+ box-shadow: 0 4px 20px rgba(99,102,241,0.35);
71
+ }
72
+ .header h1 {
73
+ font-size: 1.35rem; font-weight: 700;
74
+ background: var(--accent-g);
75
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
76
+ }
77
+ .header p { font-size: .82rem; color: var(--text-dim); margin-top: .3rem; }
78
+
79
+ .section-title {
80
+ font-size: .7rem; font-weight: 600; letter-spacing: .08em;
81
+ text-transform: uppercase; color: var(--text-dim);
82
+ margin: 1.5rem 0 .75rem; display: flex; align-items: center; gap: .5rem;
83
+ }
84
+ .section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
85
+
86
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .85rem; }
87
+ .field { display: flex; flex-direction: column; }
88
+ .field.full { grid-column: 1 / -1; }
89
+
90
+ label {
91
+ font-size: .72rem; font-weight: 500; color: var(--text-dim);
92
+ text-transform: uppercase; letter-spacing: .05em; margin-bottom: .3rem;
93
+ display: flex; gap: .4rem; align-items: baseline;
94
+ }
95
+ label .opt { font-size: .62rem; color: var(--accent); text-transform: none; letter-spacing: 0; }
96
+
97
+ input, select {
98
+ background: rgba(255,255,255,0.05); border: 1px solid var(--border);
99
+ border-radius: 10px; padding: .6rem .8rem; color: var(--text);
100
+ font-family: inherit; font-size: .88rem; outline: none;
101
+ transition: border-color .2s, box-shadow .2s;
102
+ }
103
+ input:focus, select:focus {
104
+ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
105
+ }
106
+ input::placeholder { color: rgba(255,255,255,0.2); }
107
+ select option { background: #1e1e2e; color: var(--text); }
108
+
109
+ /* ECG drop zone */
110
+ .drop {
111
+ border: 1.5px dashed var(--border); border-radius: 12px;
112
+ padding: 1.25rem; text-align: center; cursor: pointer;
113
+ transition: border-color .2s, background .2s; color: var(--text-dim);
114
+ font-size: .85rem;
115
+ }
116
+ .drop:hover, .drop.drag { border-color: var(--accent); background: rgba(99,102,241,0.06); }
117
+ .drop input { display: none; }
118
+ .drop .preview { max-height: 150px; max-width: 100%; border-radius: 8px; margin-top: .5rem; }
119
+ .drop .filename { color: var(--text); font-weight: 500; margin-top: .4rem; word-break: break-all; }
120
+
121
+ /* Mode indicator */
122
+ .mode {
123
+ margin: 1.1rem 0 .25rem; font-size: .78rem; text-align: center;
124
+ color: var(--text-dim);
125
+ }
126
+ .mode b { color: var(--accent); }
127
+
128
+ .btn {
129
+ width: 100%; margin-top: .75rem; padding: .8rem; border: none;
130
+ border-radius: 10px; background: var(--accent-g); color: #fff;
131
+ font-family: inherit; font-size: .92rem; font-weight: 600; cursor: pointer;
132
+ transition: transform .15s, box-shadow .25s, opacity .2s;
133
+ box-shadow: 0 4px 20px rgba(99,102,241,0.3);
134
+ }
135
+ .btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 28px rgba(99,102,241,0.45); }
136
+ .btn:active { transform: scale(.98); }
137
+ .btn:disabled { opacity: .55; cursor: not-allowed; }
138
+ .btn.loading .btn-text { opacity: .5; }
139
+
140
+ .error-msg {
141
+ margin-top: 1rem; padding: .75rem 1rem; background: rgba(239,68,68,0.1);
142
+ border: 1px solid rgba(239,68,68,0.25); border-radius: 10px;
143
+ color: var(--high); font-size: .82rem; display: none;
144
+ }
145
+ .error-msg.show { display: block; }
146
+
147
+ /* Result */
148
+ .result { margin-top: 1.5rem; opacity: 0; transform: translateY(10px);
149
+ transition: opacity .4s, transform .4s; display: none; }
150
+ .result.show { opacity: 1; transform: translateY(0); display: block; }
151
+
152
+ .headline {
153
+ border-radius: 12px; padding: 1.1rem 1.25rem; display: flex;
154
+ align-items: center; gap: 1rem;
155
+ }
156
+ .headline .badge {
157
+ font-size: .7rem; font-weight: 700; text-transform: uppercase;
158
+ letter-spacing: .08em; padding: .3rem .7rem; border-radius: 6px;
159
+ }
160
+ .headline .pct { font-size: 1.9rem; font-weight: 700; margin-left: auto; }
161
+ .headline .msg { font-size: .82rem; color: var(--text-dim); }
162
+ .headline .label-wrap { display: flex; flex-direction: column; gap: .3rem; }
163
+
164
+ .result.high .headline { background: rgba(239,68,68,0.12); }
165
+ .result.high .badge, .result.high .pct { color: var(--high); }
166
+ .result.high .badge { background: var(--high); color: #fff; }
167
+ .result.moderate .headline { background: rgba(245,158,11,0.12); }
168
+ .result.moderate .badge, .result.moderate .pct { color: var(--moderate); }
169
+ .result.moderate .badge { background: var(--moderate); color: #1a1a1a; }
170
+ .result.low .headline { background: rgba(34,197,94,0.12); }
171
+ .result.low .badge, .result.low .pct { color: var(--low); }
172
+ .result.low .badge { background: var(--low); color: #1a1a1a; }
173
+
174
+ .branches { margin-top: 1rem; display: flex; flex-direction: column; gap: .75rem; }
175
+ .branch {
176
+ background: var(--surface-2); border: 1px solid var(--border);
177
+ border-radius: 10px; padding: .85rem 1rem;
178
+ }
179
+ .branch h4 { font-size: .8rem; font-weight: 600; display: flex; justify-content: space-between; }
180
+ .branch h4 .b-pct { color: var(--text-dim); font-weight: 500; }
181
+ .branch .sub { font-size: .72rem; color: var(--text-dim); margin-top: .15rem; }
182
+
183
+ .prob-row { display: flex; align-items: center; gap: .6rem; margin-top: .45rem; }
184
+ .prob-label { width: 130px; font-size: .68rem; color: var(--text-dim); white-space: nowrap;
185
+ overflow: hidden; text-overflow: ellipsis; }
186
+ .prob-track { flex: 1; height: 7px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; }
187
+ .prob-fill { height: 100%; border-radius: 4px; background: var(--accent);
188
+ width: 0; transition: width .6s cubic-bezier(.4,0,.2,1); }
189
+ .prob-fill.high { background: var(--high); }
190
+ .prob-fill.moderate { background: var(--moderate); }
191
+ .prob-fill.low { background: var(--low); }
192
+ .prob-value { width: 42px; text-align: right; font-size: .72rem; font-weight: 600; }
193
+
194
+ .combined-note { font-size: .7rem; color: var(--text-dim); text-align: center; margin-top: .6rem; font-style: italic; }
195
+ .branch .warn {
196
+ margin-top: .5rem; padding: .45rem .6rem; border-radius: 7px;
197
+ background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3);
198
+ color: var(--moderate); font-size: .7rem; line-height: 1.4;
199
+ }
200
+
201
+ .disclaimer {
202
+ margin-top: 1.25rem; padding: .7rem .9rem; border-radius: 8px;
203
+ background: rgba(255,255,255,0.03); border: 1px solid var(--border);
204
+ font-size: .68rem; line-height: 1.5; color: var(--text-dim);
205
+ }
206
+ .disclaimer b { color: var(--text); }
207
+
208
+ @media (max-width: 520px) {
209
+ .form-grid { grid-template-columns: 1fr; }
210
+ .card { padding: 2rem 1.25rem 1.5rem; }
211
+ .headline { flex-wrap: wrap; }
212
+ .headline .pct { margin-left: 0; }
213
+ }
214
+ </style>
215
+ </head>
216
+ <body>
217
+ <div class="card">
218
+ <div class="header">
219
+ <div class="icon">&#x1FAC0;</div>
220
+ <h1>Multimodal Heart Attack Risk</h1>
221
+ <p>Enter patient data, upload an ECG image, or both β€” the router picks the model(s)</p>
222
+ </div>
223
+
224
+ <form id="predictForm" autocomplete="off">
225
+ <!-- ── Tabular (Framingham) ─────────────────────────────────────── -->
226
+ <div class="section-title">Patient data &nbsp;<span style="text-transform:none;letter-spacing:0;color:var(--text-dim)">(Framingham)</span></div>
227
+ <div class="form-grid">
228
+ <div class="field">
229
+ <label for="male">Sex</label>
230
+ <select id="male" name="male">
231
+ <option value="" selected>β€”</option>
232
+ <option value="1">Male</option>
233
+ <option value="0">Female</option>
234
+ </select>
235
+ </div>
236
+ <div class="field">
237
+ <label for="age">Age</label>
238
+ <input id="age" name="age" type="number" min="1" max="120" placeholder="e.g. 55" />
239
+ </div>
240
+ <div class="field">
241
+ <label for="education">Education <span class="opt">Β· optional</span></label>
242
+ <select id="education" name="education">
243
+ <option value="" selected>β€”</option>
244
+ <option value="1">1 (lowest)</option>
245
+ <option value="2">2</option>
246
+ <option value="3">3</option>
247
+ <option value="4">4 (highest)</option>
248
+ </select>
249
+ </div>
250
+ <div class="field">
251
+ <label for="currentSmoker">Current smoker</label>
252
+ <select id="currentSmoker" name="currentSmoker">
253
+ <option value="" selected>β€”</option>
254
+ <option value="1">Yes</option>
255
+ <option value="0">No</option>
256
+ </select>
257
+ </div>
258
+ <div class="field">
259
+ <label for="cigsPerDay">Cigarettes / day <span class="opt">Β· auto-filled</span></label>
260
+ <input id="cigsPerDay" name="cigsPerDay" type="number" min="0" max="100" placeholder="e.g. 0" />
261
+ </div>
262
+ <div class="field">
263
+ <label for="BPMeds">On BP meds <span class="opt">Β· auto-filled</span></label>
264
+ <select id="BPMeds" name="BPMeds">
265
+ <option value="" selected>β€”</option>
266
+ <option value="1">Yes</option>
267
+ <option value="0">No</option>
268
+ </select>
269
+ </div>
270
+ <div class="field">
271
+ <label for="prevalentStroke">Prior stroke</label>
272
+ <select id="prevalentStroke" name="prevalentStroke">
273
+ <option value="" selected>β€”</option>
274
+ <option value="1">Yes</option>
275
+ <option value="0">No</option>
276
+ </select>
277
+ </div>
278
+ <div class="field">
279
+ <label for="prevalentHyp">Hypertension</label>
280
+ <select id="prevalentHyp" name="prevalentHyp">
281
+ <option value="" selected>β€”</option>
282
+ <option value="1">Yes</option>
283
+ <option value="0">No</option>
284
+ </select>
285
+ </div>
286
+ <div class="field">
287
+ <label for="diabetes">Diabetes</label>
288
+ <select id="diabetes" name="diabetes">
289
+ <option value="" selected>β€”</option>
290
+ <option value="1">Yes</option>
291
+ <option value="0">No</option>
292
+ </select>
293
+ </div>
294
+ <div class="field">
295
+ <label for="totChol">Total cholesterol <span class="opt">Β· auto-filled</span></label>
296
+ <input id="totChol" name="totChol" type="number" step="any" min="0" placeholder="mg/dL" />
297
+ </div>
298
+ <div class="field">
299
+ <label for="sysBP">Systolic BP</label>
300
+ <input id="sysBP" name="sysBP" type="number" step="any" min="0" placeholder="mmHg" />
301
+ </div>
302
+ <div class="field">
303
+ <label for="diaBP">Diastolic BP</label>
304
+ <input id="diaBP" name="diaBP" type="number" step="any" min="0" placeholder="mmHg" />
305
+ </div>
306
+ <div class="field">
307
+ <label for="BMI">BMI <span class="opt">Β· auto-filled</span></label>
308
+ <input id="BMI" name="BMI" type="number" step="any" min="0" placeholder="kg/mΒ²" />
309
+ </div>
310
+ <div class="field">
311
+ <label for="heartRate">Heart rate <span class="opt">Β· auto-filled</span></label>
312
+ <input id="heartRate" name="heartRate" type="number" step="any" min="0" placeholder="bpm" />
313
+ </div>
314
+ <div class="field">
315
+ <label for="glucose">Glucose <span class="opt">Β· auto-filled</span></label>
316
+ <input id="glucose" name="glucose" type="number" step="any" min="0" placeholder="mg/dL" />
317
+ </div>
318
+ </div>
319
+
320
+ <!-- ── ECG image ─────────────────────────────────────────────────── -->
321
+ <div class="section-title">ECG image &nbsp;<span style="text-transform:none;letter-spacing:0;color:var(--text-dim)">(optional)</span></div>
322
+ <div class="field full">
323
+ <label class="drop" id="dropZone" for="ecg">
324
+ <input id="ecg" name="ecg" type="file" accept="image/*" />
325
+ <span id="dropText">Click or drop a 12-lead ECG image here</span>
326
+ <img class="preview" id="preview" alt="" hidden />
327
+ <div class="filename" id="filename"></div>
328
+ </label>
329
+ </div>
330
+
331
+ <div class="mode" id="modeIndicator">Will run: <b>β€”</b></div>
332
+
333
+ <button type="submit" class="btn" id="submitBtn">
334
+ <span class="btn-text">Assess Risk</span>
335
+ </button>
336
+ </form>
337
+
338
+ <div class="error-msg" id="errorMsg"></div>
339
+
340
+ <!-- Result -->
341
+ <div class="result" id="resultPanel">
342
+ <div class="headline">
343
+ <div class="label-wrap">
344
+ <span class="badge" id="badge"></span>
345
+ <span class="msg" id="riskMsg"></span>
346
+ </div>
347
+ <span class="pct" id="riskPct"></span>
348
+ </div>
349
+ <div class="branches" id="branches"></div>
350
+ <div class="combined-note" id="combinedNote" hidden></div>
351
+ </div>
352
+
353
+ <div class="disclaimer">
354
+ <b>Research / educational demo β€” not for clinical use.</b> The tabular model
355
+ (Framingham) estimates 10-year coronary-heart-disease risk; the ECG model
356
+ classifies the current ECG. They are trained on different populations with
357
+ no shared patients, so the combined score is a simple average (a transparent
358
+ heuristic), not a validated clinical measurement.
359
+ </div>
360
+ </div>
361
+
362
+ <script>
363
+ const form = document.getElementById('predictForm');
364
+ const btn = document.getElementById('submitBtn');
365
+ const errDiv = document.getElementById('errorMsg');
366
+ const result = document.getElementById('resultPanel');
367
+ const badge = document.getElementById('badge');
368
+ const riskMsg = document.getElementById('riskMsg');
369
+ const riskPct = document.getElementById('riskPct');
370
+ const branchesEl = document.getElementById('branches');
371
+ const combinedNote = document.getElementById('combinedNote');
372
+ const modeInd = document.getElementById('modeIndicator');
373
+
374
+ const TABULAR_FIELDS = ['male','age','education','currentSmoker','cigsPerDay',
375
+ 'BPMeds','prevalentStroke','prevalentHyp','diabetes','totChol','sysBP',
376
+ 'diaBP','BMI','heartRate','glucose'];
377
+
378
+ const MESSAGES = {
379
+ High: 'Elevated risk β€” recommend clinical evaluation',
380
+ Moderate: 'Borderline β€” monitor and consult a clinician',
381
+ Low: 'Lower risk β€” maintain healthy habits',
382
+ };
383
+
384
+ // ── ECG upload preview ────────────────────────────────────────────
385
+ const ecgInput = document.getElementById('ecg');
386
+ const dropZone = document.getElementById('dropZone');
387
+ const dropText = document.getElementById('dropText');
388
+ const preview = document.getElementById('preview');
389
+ const filename = document.getElementById('filename');
390
+
391
+ ecgInput.addEventListener('change', () => showEcg(ecgInput.files[0]));
392
+ ['dragover','dragenter'].forEach(ev => dropZone.addEventListener(ev, e => {
393
+ e.preventDefault(); dropZone.classList.add('drag');
394
+ }));
395
+ ['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, e => {
396
+ e.preventDefault(); dropZone.classList.remove('drag');
397
+ }));
398
+ dropZone.addEventListener('drop', e => {
399
+ if (e.dataTransfer.files.length) { ecgInput.files = e.dataTransfer.files; showEcg(ecgInput.files[0]); }
400
+ });
401
+
402
+ function showEcg(file) {
403
+ if (!file) return;
404
+ filename.textContent = file.name;
405
+ dropText.hidden = true;
406
+ preview.hidden = false;
407
+ preview.src = URL.createObjectURL(file);
408
+ updateMode();
409
+ }
410
+
411
+ // ── Mode indicator ────────────────────────────────────────────────
412
+ function hasTabular() {
413
+ return TABULAR_FIELDS.some(f => {
414
+ const el = document.getElementById(f);
415
+ return el && el.value.trim() !== '';
416
+ });
417
+ }
418
+ function hasEcg() { return ecgInput.files.length > 0; }
419
+
420
+ function updateMode() {
421
+ const t = hasTabular(), e = hasEcg();
422
+ let m = 'β€”';
423
+ if (t && e) m = 'Both β†’ averaged';
424
+ else if (t) m = 'Tabular (Framingham)';
425
+ else if (e) m = 'ECG (ResNet)';
426
+ modeInd.innerHTML = 'Will run: <b>' + m + '</b>';
427
+ }
428
+ form.addEventListener('input', updateMode);
429
+ updateMode();
430
+
431
+ // ── Submit ────────────────────────────────────────────────────────
432
+ form.addEventListener('submit', async (e) => {
433
+ e.preventDefault();
434
+ errDiv.classList.remove('show');
435
+ result.classList.remove('show', 'high', 'moderate', 'low');
436
+
437
+ if (!hasTabular() && !hasEcg()) {
438
+ return showError('Enter at least one patient field or upload an ECG image.');
439
+ }
440
+
441
+ // Build multipart payload: only non-empty fields (blanks β†’ KNN-imputed server-side)
442
+ const fd = new FormData();
443
+ TABULAR_FIELDS.forEach(f => {
444
+ const v = document.getElementById(f).value.trim();
445
+ if (v !== '') fd.append(f, v);
446
+ });
447
+ if (hasEcg()) fd.append('ecg', ecgInput.files[0]);
448
+
449
+ btn.classList.add('loading');
450
+ btn.disabled = true;
451
+ try {
452
+ const res = await fetch('/predict', { method: 'POST', body: fd });
453
+ const data = await res.json();
454
+ if (!res.ok) throw new Error(data.detail || 'Server error');
455
+ showResult(data);
456
+ } catch (err) {
457
+ showError(err.message || 'Failed to reach the server.');
458
+ } finally {
459
+ btn.classList.remove('loading');
460
+ btn.disabled = false;
461
+ }
462
+ });
463
+
464
+ function showError(msg) {
465
+ errDiv.textContent = msg;
466
+ errDiv.classList.add('show');
467
+ }
468
+
469
+ // ── Render result ─────────────────────────────────────────────────
470
+ function pctStr(p) { return (p * 100).toFixed(1) + '%'; }
471
+ function pretty(c) {
472
+ return c.replace(/_ecg_images$/, '').replace(/_/g, ' ')
473
+ .replace(/\bmi\b/gi, 'MI')
474
+ .replace(/\b\w/g, m => m.toUpperCase());
475
+ }
476
+
477
+ function showResult(data) {
478
+ const level = data.risk_level;
479
+ result.className = 'result ' + level.toLowerCase();
480
+ badge.textContent = level + ' risk';
481
+ riskMsg.textContent = MESSAGES[level] || '';
482
+ riskPct.textContent = pctStr(data.p_risk);
483
+
484
+ branchesEl.innerHTML = '';
485
+ const b = data.branches || {};
486
+
487
+ if (b.tabular) {
488
+ const t = b.tabular;
489
+ const imp = (t.imputed_fields && t.imputed_fields.length)
490
+ ? `KNN-imputed: ${t.imputed_fields.join(', ')}` : 'No fields imputed';
491
+ branchesEl.appendChild(branchCard(
492
+ 'Tabular Β· Framingham 10-yr CHD', t.p_risk, t.band,
493
+ [['P(CHD)', t.detail['CHD']]], imp));
494
+ }
495
+ if (b.ecg) {
496
+ const ec = b.ecg;
497
+ const rows = Object.entries(ec.detail).map(([k, v]) => [pretty(k), v]);
498
+ const conf = (ec.confidence != null)
499
+ ? ` Β· confidence ${(ec.confidence * 100).toFixed(1)}%` : '';
500
+ branchesEl.appendChild(branchCard(
501
+ 'ECG Β· ResNet', ec.p_risk, ec.band, rows,
502
+ 'Predicted: ' + pretty(ec.ecg_class) + conf, ec.warning));
503
+ }
504
+
505
+ if (data.mode === 'multimodal') {
506
+ combinedNote.hidden = false;
507
+ combinedNote.textContent =
508
+ `Combined = average of tabular (${pctStr(b.tabular.p_risk)}) and ECG (${pctStr(b.ecg.p_risk)})`;
509
+ } else {
510
+ combinedNote.hidden = true;
511
+ }
512
+
513
+ requestAnimationFrame(() => {
514
+ result.classList.add('show');
515
+ setTimeout(() => {
516
+ result.querySelectorAll('.prob-fill').forEach(el => {
517
+ el.style.width = el.dataset.w + '%';
518
+ });
519
+ }, 80);
520
+ });
521
+ }
522
+
523
+ function branchCard(title, pRisk, band, rows, subtitle, warning) {
524
+ const card = document.createElement('div');
525
+ card.className = 'branch';
526
+ const header = `<h4>${title}<span class="b-pct">${pctStr(pRisk)} Β· ${band}</span></h4>`;
527
+ const sub = subtitle ? `<div class="sub">${subtitle}</div>` : '';
528
+ const bars = rows.map(([label, val]) => {
529
+ const w = (val * 100).toFixed(1);
530
+ return `<div class="prob-row">
531
+ <span class="prob-label" title="${label}">${label}</span>
532
+ <div class="prob-track"><div class="prob-fill" data-w="${w}"></div></div>
533
+ <span class="prob-value">${w}%</span>
534
+ </div>`;
535
+ }).join('');
536
+ const warn = warning ? `<div class="warn">&#9888; ${warning}</div>` : '';
537
+ card.innerHTML = header + sub + bars + warn;
538
+ return card;
539
+ }
540
+ </script>
541
+ </body>
542
+ </html>