Deploy v2: multimodal ensemble router (Framingham tabular + ECG ResNet)
Browse files- Dockerfile +19 -18
- README.md +289 -432
- app.py +134 -82
- inference/__init__.py +1 -0
- inference/ecg.py +97 -0
- inference/framingham.py +68 -0
- inference/fusion.py +44 -0
- inference/validation.py +49 -0
- models/ecg_classes.json +26 -0
- models/ecg_resnet.pt +3 -0
- models/framingham_pipeline.joblib +3 -0
- requirements.txt +18 -5
- static/index.html +542 -429
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
|
| 12 |
-
COPY
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
- [
|
| 30 |
-
- [
|
| 31 |
-
- [
|
| 32 |
-
- [
|
| 33 |
-
- [
|
| 34 |
-
- [
|
| 35 |
-
- [
|
| 36 |
-
|
| 37 |
-
---
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
---
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
```
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
| 121 |
-
|
|
| 122 |
-
|
|
| 123 |
-
|
|
| 124 |
-
|
|
| 125 |
-
|
| 126 |
-
**
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
#
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
-
|
| 156 |
-
|
| 157 |
-
#
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
#
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
```bash
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
.
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
```
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
]
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 8 |
-
<style>
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
--
|
| 14 |
-
--surface:
|
| 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:
|
| 33 |
-
justify-content: center;
|
| 34 |
-
padding:
|
| 35 |
-
overflow-x: hidden;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
width: 60vw; height: 60vw;
|
| 44 |
-
background: radial-gradient(circle, rgba(99,102,241,0.18), transparent 70%);
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
.header
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
display:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
.
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
.
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
border-radius:
|
| 112 |
-
padding: .
|
| 113 |
-
color: var(--text);
|
| 114 |
-
font-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
margin-top: .
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
}
|
| 156 |
-
.
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
.
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
}
|
| 169 |
-
.result.
|
| 170 |
-
|
| 171 |
-
.result
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
.
|
| 191 |
-
.
|
| 192 |
-
.
|
| 193 |
-
|
| 194 |
-
.
|
| 195 |
-
.
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
</
|
| 258 |
-
<
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
<
|
| 278 |
-
|
| 279 |
-
<
|
| 280 |
-
<
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
<
|
| 286 |
-
<
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
<
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
<
|
| 306 |
-
<
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
<
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
<
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">🫀</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 <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 <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">⚠ ${warning}</div>` : '';
|
| 537 |
+
card.innerHTML = header + sub + bars + warn;
|
| 538 |
+
return card;
|
| 539 |
+
}
|
| 540 |
+
</script>
|
| 541 |
+
</body>
|
| 542 |
+
</html>
|