Shahd1sayed/heart-attack-risk-predictor
1
1"""Multimodal heart-attack risk app — ensemble router.2 3One ``POST /predict`` endpoint accepts multipart/form-data carrying *optional*4Framingham tabular fields and an *optional* ECG image. An internal router picks5the path:6 7 tabular only -> Model A (Framingham CHD)8 ECG only -> Model B (ResNet ECG)9 both -> A + B, averaged10 neither -> HTTP 40011 12The predictor modules are imported lazily so the app still boots (and serves the13frontend) before the models have been trained.14 15Run: uvicorn app:app --reload16"""17from __future__ import annotations18 19from pathlib import Path20from typing import Optional21 22from fastapi import FastAPI, File, Form, HTTPException, UploadFile23from fastapi.concurrency import run_in_threadpool24from fastapi.responses import FileResponse25from fastapi.staticfiles import StaticFiles26 27from inference.fusion import band_for, combine28from inference.validation import validate_tabular29 30# Canonical Framingham feature order (mirrors train_framingham.FEATURES).31FEATURES = [32 "male", "age", "education", "currentSmoker", "cigsPerDay", "BPMeds",33 "prevalentStroke", "prevalentHyp", "diabetes", "totChol", "sysBP",34 "diaBP", "BMI", "heartRate", "glucose",35]36 37app = FastAPI(title="Multimodal Heart Attack Risk — Ensemble Router")38 39 40def _has_value(v: Optional[str]) -> bool:41 return v is not None and str(v).strip() != ""42 43 44@app.post("/predict")45async 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 requests91 # don't block the event loop (torch/sklearn release the GIL).92 if has_tabular:93 try:94 from inference.framingham import predict_tabular95 branches["tabular"] = await run_in_threadpool(predict_tabular, fields)96 except FileNotFoundError as exc:97 raise HTTPException(status_code=503, detail=str(exc)) from exc98 99 if has_ecg:100 image_bytes = await ecg.read()101 try:102 from inference.ecg import predict_ecg103 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 exc106 except ValueError as exc:107 raise HTTPException(status_code=400, detail=str(exc)) from exc108 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", p113 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 ───────────────────────────────────────────────────────────127STATIC_DIR = Path(__file__).parent / "static"128STATIC_DIR.mkdir(exist_ok=True)129app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")130 131 132@app.get("/")133def serve_frontend():134 return FileResponse(str(STATIC_DIR / "index.html"))135 