3v324v23 commited on
Commit
9686dbe
·
1 Parent(s): 469c5cb

Deploy SheGuard - Maternal Risk Assessment with Mamba3 SSM

Browse files
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Personal notes (not for deployment)
2
+ NOTES.md
3
+ post.md
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.pyo
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ *.egg
13
+
14
+ # Virtual environments
15
+ .venv/
16
+ venv/
17
+ env/
18
+
19
+ # IDE / Editor
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+ *.swo
24
+ *~
25
+
26
+ # OS files
27
+ .DS_Store
28
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install Tesseract OCR + system deps
4
+ RUN apt-get update && \
5
+ apt-get install -y --no-install-recommends \
6
+ tesseract-ocr \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 && \
9
+ rm -rf /var/lib/apt/lists/*
10
+
11
+ WORKDIR /app
12
+
13
+ # Install Python deps first (layer caching)
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy project files
18
+ COPY . .
19
+
20
+ # Train the model at build time (generates weights)
21
+ RUN python -m src.train
22
+
23
+ # HF Spaces expects port 7860
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,46 @@
1
  ---
2
  title: SheGuard
3
- emoji: 🐢
4
- colorFrom: indigo
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
8
- short_description: prevention is better than cure -- the motto are model follow
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: SheGuard
3
+ emoji: 🤱
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ short_description: Maternal mortality early warning using Mamba3 SSM AI
9
  ---
10
 
11
+ # SheGuard Maternal Risk Assessment
12
+
13
+ AI-powered maternal mortality early warning system, built with **Mamba3 Sequential State-Space Models** and **WHO clinical safety rules**.
14
+
15
+ ## Features
16
+
17
+ - **Mamba3 SSM model** — Temporal sequence analysis of prenatal vital signs
18
+ - **3-tier alert system** — GREEN / AMBER / RED risk classification
19
+ - **WHO clinical safety net** — Hard rule overrides for obvious danger signs
20
+ - **OCR auto-fill** — Photograph a paper prenatal record card to auto-populate visit data
21
+ - **Resource-aware routing** — Generates transfer orders when clinic lacks blood supply or staff
22
+ - **Explainable AI** — Shows top contributing features for each prediction
23
+
24
+ ## How to Use
25
+
26
+ 1. Enter patient vitals from prenatal visits (or upload a photo of the record card)
27
+ 2. Click **"Assess maternal risk"**
28
+ 3. View the risk level, contributing factors, and recommended clinical actions
29
+
30
+ ## Architecture
31
+
32
+ ```
33
+ Patient vitals → Mamba3 SSM (5-visit sequence) → Risk prediction
34
+
35
+ WHO Clinical Safety Rules
36
+
37
+ GREEN / AMBER / RED alert
38
+ ```
39
+
40
+ ## Tech Stack
41
+
42
+ - **Model:** PyTorch, Mamba3 SSM (Trapezoidal discretization + Complex state + MIMO)
43
+ - **API:** FastAPI + Uvicorn
44
+ - **Frontend:** HTML/CSS/JS dashboard
45
+ - **OCR:** OpenCV + Pytesseract
46
+ - **Dataset:** UCI Maternal Health Risk (1,014 samples)
api/__init__.py ADDED
File without changes
api/alert_logic.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — Alert Logic
3
+ Three-tier alerting with suppression, clinical safety net, and resource-aware routing.
4
+ """
5
+
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional, Dict
8
+ from api.schemas import AlertTier
9
+
10
+ # ─── Thresholds ────────────────────────────────────────────────────────────────
11
+ RED_THRESHOLD = 0.85
12
+ AMBER_THRESHOLD = 0.65
13
+ RED_CONSECUTIVE_VISITS = 2
14
+ SUPPRESSION_HOURS = 48
15
+ SUPPRESSION_DELTA = 0.15
16
+
17
+ # In-memory alert store (use Redis/DB in production)
18
+ _alert_history: Dict[str, dict] = {}
19
+
20
+
21
+ # ─── Clinical rule safety net ─────────────────────────────────────────────────
22
+
23
+ def apply_clinical_safety_net(visits_raw: list) -> tuple[str | None, str | None]:
24
+ """
25
+ Checks raw visit values against WHO clinical thresholds.
26
+ Returns (forced_tier, reason) if a rule fires, else (None, None).
27
+ """
28
+ max_systolic = max((v.get("systolic_bp") or 0) for v in visits_raw)
29
+ max_diastolic = max((v.get("diastolic_bp") or 0) for v in visits_raw)
30
+ max_bs = max((v.get("blood_sugar") or 0) for v in visits_raw)
31
+
32
+ latest = visits_raw[-1]
33
+ latest_sys = latest.get("systolic_bp") or 0
34
+ latest_dia = latest.get("diastolic_bp") or 0
35
+
36
+ # ── RED RULES ──────────────────────────────────────────────────────────────
37
+
38
+ # Rule 1: Severe hypertension → RED
39
+ if max_systolic >= 160 or max_diastolic >= 110:
40
+ return "RED", (
41
+ f"SystolicBP {max_systolic} mmHg meets WHO severe "
42
+ f"hypertension threshold (≥160) — emergency referral required"
43
+ )
44
+
45
+ # Rule 5: Multi-vital simultaneous escalation → RED
46
+ if len(visits_raw) >= 3:
47
+ first = visits_raw[0]
48
+ last = visits_raw[-1]
49
+
50
+ sys_rise = (last.get("systolic_bp") or 0) - (first.get("systolic_bp") or 0)
51
+ dia_rise = (last.get("diastolic_bp") or 0) - (first.get("diastolic_bp") or 0)
52
+ bs_rise = (last.get("blood_sugar") or 0) - (first.get("blood_sugar") or 0)
53
+ hr_rise = (last.get("heart_rate") or 0) - (first.get("heart_rate") or 0)
54
+ temp_rise = (last.get("body_temp") or 0) - (first.get("body_temp") or 0)
55
+
56
+ escalating_count = 0
57
+ if sys_rise >= 15: escalating_count += 1
58
+ if dia_rise >= 10: escalating_count += 1
59
+ if bs_rise >= 3.0: escalating_count += 1
60
+ if hr_rise >= 15: escalating_count += 1
61
+ if temp_rise >= 0.5: escalating_count += 1
62
+
63
+ if escalating_count >= 3:
64
+ return "RED", (
65
+ f"{escalating_count} vitals escalating simultaneously "
66
+ f"(BP +{sys_rise:.0f} mmHg, HR +{hr_rise:.0f} bpm, "
67
+ f"BS +{bs_rise:.1f} mmol/L) — combined deterioration "
68
+ f"pattern, refer immediately"
69
+ )
70
+
71
+ # ── AMBER RULES ────────────────────────────────────────────────────────────
72
+
73
+ # Rule 2: Hypertension in pregnancy → AMBER
74
+ if latest_sys >= 140 or latest_dia >= 90:
75
+ return "AMBER", (
76
+ f"SystolicBP {latest_sys} mmHg meets WHO hypertension "
77
+ f"in pregnancy threshold (≥140)"
78
+ )
79
+
80
+ # Rule 3: Severe hyperglycaemia → AMBER
81
+ if max_bs > 11.1:
82
+ return "AMBER", (
83
+ f"Blood sugar {max_bs} mmol/L exceeds gestational "
84
+ f"diabetes threshold (>11.1)"
85
+ )
86
+
87
+ # Rule 4: BP escalation pattern → AMBER
88
+ if len(visits_raw) >= 2:
89
+ first_sys = visits_raw[0].get("systolic_bp") or 0
90
+ bp_rise = latest_sys - first_sys
91
+ if bp_rise >= 20:
92
+ return "AMBER", (
93
+ f"SystolicBP rose {bp_rise:.0f} mmHg across visits "
94
+ f"— escalation pattern detected"
95
+ )
96
+
97
+ return None, None
98
+
99
+
100
+ def compute_alert_tier(
101
+ probabilities: dict,
102
+ patient_id: str,
103
+ visit_importances: list,
104
+ ) -> tuple[AlertTier, bool]:
105
+ """
106
+ Computes the alert tier for a patient.
107
+ Returns: (alert_tier, is_suppressed)
108
+ """
109
+ high_risk_prob = probabilities.get("High risk", 0.0)
110
+
111
+ # Check suppression
112
+ if patient_id in _alert_history:
113
+ prev = _alert_history[patient_id]
114
+ time_since = datetime.now() - prev["last_alerted"]
115
+ score_delta = high_risk_prob - prev["last_score"]
116
+
117
+ if time_since < timedelta(hours=SUPPRESSION_HOURS):
118
+ if score_delta < SUPPRESSION_DELTA:
119
+ return prev["tier"], True
120
+
121
+ # Determine tier
122
+ if high_risk_prob >= RED_THRESHOLD:
123
+ recent_weight = sum(visit_importances[-RED_CONSECUTIVE_VISITS:])
124
+ if recent_weight > 0.4:
125
+ tier = AlertTier.RED
126
+ else:
127
+ tier = AlertTier.AMBER
128
+ elif high_risk_prob >= AMBER_THRESHOLD:
129
+ tier = AlertTier.AMBER
130
+ else:
131
+ tier = AlertTier.GREEN
132
+
133
+ # Store in history
134
+ _alert_history[patient_id] = {
135
+ "last_alerted": datetime.now(),
136
+ "last_score": high_risk_prob,
137
+ "tier": tier,
138
+ }
139
+
140
+ return tier, False
141
+
142
+
143
+ def generate_action_text(
144
+ tier: AlertTier,
145
+ top_reasons: list,
146
+ data_quality: float,
147
+ staff_available: Optional[int],
148
+ blood_units: Optional[int],
149
+ ) -> tuple[str, Optional[str]]:
150
+ """Generates actionable text for the health worker and optional transfer order."""
151
+
152
+ transfer_order = None
153
+ reasons_text = "; ".join(top_reasons) if top_reasons else "multiple elevated indicators"
154
+
155
+ if tier == AlertTier.GREEN:
156
+ action = "Continue standard prenatal care schedule. No immediate action required."
157
+
158
+ elif tier == AlertTier.AMBER:
159
+ action = (
160
+ f"AMBER ALERT: Elevated risk detected ({reasons_text}). "
161
+ f"Schedule additional checkup within 72 hours. "
162
+ f"Increase monitoring frequency to weekly."
163
+ )
164
+ if data_quality < 0.7:
165
+ action += " NOTE: Data quality is low — ensure all readings are recorded at next visit."
166
+
167
+ else: # RED
168
+ action = (
169
+ f"🔴 RED ALERT: High maternal risk detected ({reasons_text}). "
170
+ f"Refer patient to hospital immediately for full assessment."
171
+ )
172
+
173
+ # Resource-aware routing
174
+ resources_ok = True
175
+ resource_warnings = []
176
+
177
+ if staff_available is not None and staff_available == 0:
178
+ resources_ok = False
179
+ resource_warnings.append("no doctors currently on duty")
180
+ if blood_units is not None and blood_units < 2:
181
+ resources_ok = False
182
+ resource_warnings.append("insufficient blood supply (<2 units)")
183
+
184
+ if not resources_ok:
185
+ issues = " and ".join(resource_warnings)
186
+ transfer_order = (
187
+ f"TRANSFER ORDER: This clinic has {issues}. "
188
+ f"Patient must be transferred to nearest equipped facility. "
189
+ f"Call referral line: 1800-MAMA-REF. "
190
+ f"Document: Patient at high risk of {reasons_text}."
191
+ )
192
+ action += f" ⚠️ Resources insufficient — transfer order generated."
193
+
194
+ if data_quality < 0.7:
195
+ action += " NOTE: Prediction confidence is reduced due to missing readings."
196
+
197
+ return action, transfer_order
api/extract_report.py ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/extract_report.py
2
+ """
3
+ MamaGuard — Report Extraction (OCR)
4
+ Ensemble preprocessing × ensemble OCR for extracting prenatal data from images.
5
+ Handles screenshots, paper photos, scans, low-light, rotated, colored forms, etc.
6
+ """
7
+
8
+ import base64
9
+ import io
10
+ import re
11
+ import pytesseract
12
+ from PIL import Image
13
+ import cv2
14
+ import numpy as np
15
+ from fastapi import APIRouter, HTTPException
16
+ from pydantic import BaseModel
17
+ from typing import Optional, List, Tuple
18
+
19
+ router = APIRouter()
20
+
21
+ # Tesseract path (Windows)
22
+ pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
23
+
24
+
25
+ # ── Schemas ───────────────────────────────────────────────────────────────────
26
+
27
+ class ExtractRequest(BaseModel):
28
+ image_base64: str
29
+ image_type: str = "image/jpeg"
30
+
31
+ class ExtractedVisit(BaseModel):
32
+ age: Optional[float] = None
33
+ systolic_bp: Optional[float] = None
34
+ diastolic_bp: Optional[float] = None
35
+ blood_sugar: Optional[float] = None
36
+ body_temp: Optional[float] = None
37
+ heart_rate: Optional[float] = None
38
+ visit_date: Optional[str] = None
39
+
40
+ class ExtractResponse(BaseModel):
41
+ visits: List[ExtractedVisit]
42
+ patient_id: Optional[str] = None
43
+ notes: Optional[str] = None
44
+ confidence: float = 1.0
45
+ raw_text: Optional[str] = None
46
+
47
+
48
+ # ── Medical ranges (values outside = OCR errors, discard) ─────────────────────
49
+ RANGES = {
50
+ "age": (10, 60),
51
+ "systolic_bp": (70, 200),
52
+ "diastolic_bp": (40, 130),
53
+ "blood_sugar": (3.0, 20.0),
54
+ "body_temp": (35.0, 42.0),
55
+ "heart_rate": (40, 160),
56
+ }
57
+
58
+ # Field label aliases
59
+ ROW_ALIASES = {
60
+ "age": ["age", "years", "yr", "patient age", "age (yrs)"],
61
+ "systolic_bp": ["systolic", "sbp", "sys", "systolic bp", "bp sys",
62
+ "upper bp", "s.b.p", "s bp", "syst"],
63
+ "diastolic_bp": ["diastolic", "dbp", "dia", "diastolic bp", "bp dia",
64
+ "lower bp", "d.b.p", "d bp", "diast"],
65
+ "blood_sugar": ["blood sugar", "bs", "glucose", "bg", "blood glucose",
66
+ "sugar", "b.s", "rbs", "fbs", "ppbs", "glu"],
67
+ "body_temp": ["temp", "temperature", "body temp", "tmp", "fever",
68
+ "body temperature", "b.temp", "t (c)", "t(c)"],
69
+ "heart_rate": ["heart rate", "hr", "pulse", "bpm", "heartrate",
70
+ "heart", "p/r", "pr", "pulse rate"],
71
+ }
72
+
73
+ # Lines containing these words are skipped during parsing
74
+ SKIP_KEYWORDS = [
75
+ "field", "visit", "v1", "v2", "v3", "v 1", "v 2", "v 3",
76
+ "column", "header", "parameter", "reading", "measurement",
77
+ "no.", "sl.", "s.no", "item"
78
+ ]
79
+
80
+
81
+ # ═══════════════════════════════════════════════════════════════════════════════
82
+ # IMAGE PREPROCESSING PIPELINES
83
+ # ═══════════════════════════════════════════════════════════════════════════════
84
+
85
+ def to_gray(pil_image: Image.Image) -> np.ndarray:
86
+ """Convert PIL image to OpenCV grayscale array."""
87
+ return cv2.cvtColor(np.array(pil_image.convert("RGB")), cv2.COLOR_RGB2GRAY)
88
+
89
+
90
+ def smart_upscale(gray: np.ndarray, target_width: int = 1400) -> np.ndarray:
91
+ """Upscale small images so Tesseract can read text reliably."""
92
+ h, w = gray.shape
93
+ if w < target_width:
94
+ scale = target_width / w
95
+ gray = cv2.resize(gray, None, fx=scale, fy=scale,
96
+ interpolation=cv2.INTER_CUBIC)
97
+ return gray
98
+
99
+
100
+ def detect_and_invert_if_dark(gray: np.ndarray) -> np.ndarray:
101
+ """Invert dark-background images for Tesseract (trained on black-on-white)."""
102
+ if np.mean(gray) < 127:
103
+ return cv2.bitwise_not(gray)
104
+ return gray
105
+
106
+
107
+ def remove_shadow(gray: np.ndarray) -> np.ndarray:
108
+ """Remove uneven lighting via background subtraction."""
109
+ dilated = cv2.dilate(gray, np.ones((7, 7), np.uint8))
110
+ bg = cv2.medianBlur(dilated, 21)
111
+ diff = 255 - cv2.absdiff(gray, bg)
112
+ norm = cv2.normalize(diff, None, 0, 255, cv2.NORM_MINMAX)
113
+ return norm.astype(np.uint8)
114
+
115
+
116
+ def enhance_contrast_clahe(gray: np.ndarray) -> np.ndarray:
117
+ """Apply CLAHE for adaptive local contrast enhancement."""
118
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
119
+ return clahe.apply(gray)
120
+
121
+
122
+ def deskew(gray: np.ndarray) -> np.ndarray:
123
+ """Correct image rotation using minimum-area bounding rectangle."""
124
+ _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
125
+ coords = np.column_stack(np.where(binary > 0))
126
+ if len(coords) < 100:
127
+ return gray
128
+ angle = cv2.minAreaRect(coords)[-1]
129
+ if angle < -45:
130
+ angle = 90 + angle
131
+ if abs(angle) > 15:
132
+ return gray
133
+ (h, w) = gray.shape
134
+ M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
135
+ return cv2.warpAffine(gray, M, (w, h),
136
+ flags=cv2.INTER_CUBIC,
137
+ borderMode=cv2.BORDER_REPLICATE)
138
+
139
+
140
+ def remove_ruled_lines(gray: np.ndarray) -> np.ndarray:
141
+ """Remove horizontal ruled lines that confuse text segmentation."""
142
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1))
143
+ lines = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel, iterations=2)
144
+ return cv2.subtract(gray, lines)
145
+
146
+
147
+ # ── 8 Preprocessing pipelines ─────────────────────────────────────────────────
148
+
149
+ def pipeline_standard(gray: np.ndarray) -> np.ndarray:
150
+ """Pipeline A: Clean printed document, white paper, good lighting."""
151
+ gray = smart_upscale(gray)
152
+ gray = detect_and_invert_if_dark(gray)
153
+ gray = cv2.fastNlMeansDenoising(gray, h=10)
154
+ return cv2.adaptiveThreshold(gray, 255,
155
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)
156
+
157
+
158
+ def pipeline_shadow(gray: np.ndarray) -> np.ndarray:
159
+ """Pipeline B: Paper photo with shadow or uneven lighting."""
160
+ gray = smart_upscale(gray)
161
+ gray = detect_and_invert_if_dark(gray)
162
+ gray = remove_shadow(gray)
163
+ gray = cv2.fastNlMeansDenoising(gray, h=8)
164
+ return cv2.adaptiveThreshold(gray, 255,
165
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 41, 12)
166
+
167
+
168
+ def pipeline_low_contrast(gray: np.ndarray) -> np.ndarray:
169
+ """Pipeline C: Faded ink, old paper, poor scan quality."""
170
+ gray = smart_upscale(gray)
171
+ gray = detect_and_invert_if_dark(gray)
172
+ gray = enhance_contrast_clahe(gray)
173
+ gray = cv2.fastNlMeansDenoising(gray, h=12)
174
+ blurred = cv2.GaussianBlur(gray, (0, 0), 3)
175
+ gray = cv2.addWeighted(gray, 1.5, blurred, -0.5, 0)
176
+ return cv2.adaptiveThreshold(gray, 255,
177
+ cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 21, 8)
178
+
179
+
180
+ def pipeline_deskewed(gray: np.ndarray) -> np.ndarray:
181
+ """Pipeline D: Rotated or skewed image."""
182
+ gray = smart_upscale(gray)
183
+ gray = detect_and_invert_if_dark(gray)
184
+ gray = deskew(gray)
185
+ gray = cv2.fastNlMeansDenoising(gray, h=10)
186
+ return cv2.adaptiveThreshold(gray, 255,
187
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)
188
+
189
+
190
+ def pipeline_ruled_paper(gray: np.ndarray) -> np.ndarray:
191
+ """Pipeline E: Handwritten on ruled/lined paper."""
192
+ gray = smart_upscale(gray)
193
+ gray = detect_and_invert_if_dark(gray)
194
+ gray = remove_shadow(gray)
195
+ gray = remove_ruled_lines(gray)
196
+ gray = enhance_contrast_clahe(gray)
197
+ gray = cv2.fastNlMeansDenoising(gray, h=15)
198
+ return cv2.adaptiveThreshold(gray, 255,
199
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 25, 8)
200
+
201
+
202
+ def pipeline_high_noise(gray: np.ndarray) -> np.ndarray:
203
+ """Pipeline F: Low-light photo, dim room, heavily compressed JPEG."""
204
+ gray = smart_upscale(gray, target_width=1800)
205
+ gray = detect_and_invert_if_dark(gray)
206
+ gray = cv2.fastNlMeansDenoising(gray, h=20,
207
+ templateWindowSize=7, searchWindowSize=21)
208
+ gray = enhance_contrast_clahe(gray)
209
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
210
+ gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)
211
+ _, out = cv2.threshold(gray, 0, 255,
212
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
213
+ return out
214
+
215
+
216
+ def pipeline_screenshot(gray: np.ndarray) -> np.ndarray:
217
+ """Pipeline G: Screenshot (pixel-perfect, minimal preprocessing needed)."""
218
+ gray = smart_upscale(gray)
219
+ gray = detect_and_invert_if_dark(gray)
220
+ _, out = cv2.threshold(gray, 0, 255,
221
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
222
+ return out
223
+
224
+
225
+ def pipeline_color_form(pil_image: Image.Image) -> np.ndarray:
226
+ """Pipeline H: Colored background — uses LAB color space for contrast."""
227
+ img_bgr = cv2.cvtColor(np.array(pil_image.convert("RGB")), cv2.COLOR_RGB2BGR)
228
+ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
229
+ l_chan = lab[:, :, 0]
230
+ l_chan = smart_upscale(l_chan)
231
+ l_chan = detect_and_invert_if_dark(l_chan)
232
+ l_chan = enhance_contrast_clahe(l_chan)
233
+ l_chan = cv2.fastNlMeansDenoising(l_chan, h=10)
234
+ return cv2.adaptiveThreshold(l_chan, 255,
235
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)
236
+
237
+
238
+ PIPELINE_FNS = {
239
+ "standard": pipeline_standard,
240
+ "shadow": pipeline_shadow,
241
+ "low_contrast": pipeline_low_contrast,
242
+ "deskewed": pipeline_deskewed,
243
+ "ruled_paper": pipeline_ruled_paper,
244
+ "high_noise": pipeline_high_noise,
245
+ "screenshot": pipeline_screenshot,
246
+ }
247
+
248
+ PIPELINE_ORDERS = {
249
+ "screenshot": ["screenshot", "standard", "low_contrast"],
250
+ "dark_bg": ["screenshot", "standard", "shadow"],
251
+ "low_contrast": ["low_contrast", "shadow", "standard", "high_noise"],
252
+ "colored_form": ["color_form", "low_contrast", "standard"],
253
+ "camera_photo": ["shadow", "deskewed", "ruled_paper", "standard"],
254
+ "standard": ["standard", "shadow", "low_contrast", "deskewed"],
255
+ }
256
+
257
+
258
+ # ═══════════════════════════════════════════════════════════════════════════════
259
+ # IMAGE TYPE DETECTOR
260
+ # ═══════════════════════════════════════════════════════════════════════════════
261
+
262
+ def detect_image_type(gray: np.ndarray, pil_image: Image.Image) -> str:
263
+ """Heuristically classify the image to select optimal pipeline order."""
264
+ mean_b = float(np.mean(gray))
265
+ std_b = float(np.std(gray))
266
+ h, w = gray.shape
267
+
268
+ if std_b > 75 and (mean_b < 70 or mean_b > 185):
269
+ return "screenshot"
270
+
271
+ if mean_b < 85:
272
+ return "dark_bg"
273
+
274
+ if std_b < 35:
275
+ return "low_contrast"
276
+
277
+ img_hsv = cv2.cvtColor(np.array(pil_image.convert("RGB")), cv2.COLOR_RGB2HSV)
278
+ mean_sat = float(np.mean(img_hsv[:, :, 1]))
279
+ if mean_sat > 40:
280
+ return "colored_form"
281
+
282
+ if w > 2000 or h > 2000:
283
+ return "camera_photo"
284
+
285
+ return "standard"
286
+
287
+
288
+ # ═══════════════════════════════════════════════════════════════════════════════
289
+ # OCR ENGINE
290
+ # ═══════════════════════════════════════════════════════════════════════════════
291
+
292
+ OCR_CONFIGS = [
293
+ "--psm 6 --oem 3",
294
+ "--psm 4 --oem 3",
295
+ "--psm 11 --oem 3",
296
+ "--psm 3 --oem 3",
297
+ ]
298
+
299
+
300
+ def run_ocr_best(processed_img: np.ndarray) -> str:
301
+ """Run Tesseract with all PSM configs and return the output with most digits."""
302
+ pil = Image.fromarray(processed_img)
303
+ best_text = ""
304
+ best_digits = 0
305
+ for config in OCR_CONFIGS:
306
+ try:
307
+ text = pytesseract.image_to_string(pil, config=config)
308
+ digits = sum(c.isdigit() for c in text)
309
+ if digits > best_digits:
310
+ best_digits = digits
311
+ best_text = text
312
+ except Exception:
313
+ continue
314
+ return best_text
315
+
316
+
317
+ # ═══════════════════════════════════════════════════════════════════════════════
318
+ # PARSERS
319
+ # ═══════════════════════════════════════════════════════════════════════════════
320
+
321
+ def find_numbers(text: str) -> List[float]:
322
+ """Extract all integers and decimals from a string."""
323
+ return [float(m) for m in re.findall(r'\d+\.?\d*', text)]
324
+
325
+
326
+ def match_label(line: str) -> Optional[str]:
327
+ """Check if a line contains a known field name alias. Returns field key or None."""
328
+ ll = line.lower()
329
+ for field, aliases in ROW_ALIASES.items():
330
+ for alias in aliases:
331
+ if alias in ll:
332
+ return field
333
+ return None
334
+
335
+
336
+ def is_skip_line(line: str) -> bool:
337
+ """Returns True if this line is a header or label-only row to skip."""
338
+ ll = line.lower()
339
+ return any(kw in ll for kw in SKIP_KEYWORDS)
340
+
341
+
342
+ def parse_table_format(raw_text: str) -> List[dict]:
343
+ """Parser A — Row-per-field table layout."""
344
+ lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
345
+ field_vals = {}
346
+ max_visits = 0
347
+
348
+ for line in lines:
349
+ if is_skip_line(line):
350
+ continue
351
+ field = match_label(line)
352
+ if field is None:
353
+ continue
354
+ numbers = find_numbers(line)
355
+ lo, hi = RANGES[field]
356
+ valid = [n for n in numbers if lo <= n <= hi]
357
+ if valid:
358
+ field_vals[field] = valid
359
+ max_visits = max(max_visits, len(valid))
360
+
361
+ if not field_vals:
362
+ return []
363
+
364
+ return [
365
+ {field: (values[i] if i < len(values) else None)
366
+ for field, values in field_vals.items()}
367
+ for i in range(max_visits)
368
+ ]
369
+
370
+
371
+ def parse_column_format(raw_text: str) -> List[dict]:
372
+ """Parser B — Column-per-visit layout with header row."""
373
+ lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
374
+ header_idx = -1
375
+ header_fields = []
376
+
377
+ for i, line in enumerate(lines):
378
+ parts = re.split(r'\s{2,}|\t', line)
379
+ known = [match_label(p) for p in parts]
380
+ if sum(f is not None for f in known) >= 3:
381
+ header_idx = i
382
+ header_fields = known
383
+ break
384
+
385
+ if header_idx == -1:
386
+ return []
387
+
388
+ visits = []
389
+ for line in lines[header_idx + 1:]:
390
+ if is_skip_line(line):
391
+ continue
392
+ parts = re.split(r'\s{2,}|\t', line)
393
+ visit = {}
394
+ for col_idx, field in enumerate(header_fields):
395
+ if field is None or col_idx >= len(parts):
396
+ continue
397
+ nums = find_numbers(parts[col_idx])
398
+ if nums:
399
+ lo, hi = RANGES[field]
400
+ val = nums[0]
401
+ if lo <= val <= hi:
402
+ visit[field] = val
403
+ if visit:
404
+ visits.append(visit)
405
+ return visits
406
+
407
+
408
+ def parse_key_value_format(raw_text: str) -> List[dict]:
409
+ """Parser C — Key-value format (one field per line)."""
410
+ lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
411
+ visit = {}
412
+
413
+ for line in lines:
414
+ if is_skip_line(line):
415
+ continue
416
+ field = match_label(line)
417
+ if field is None:
418
+ continue
419
+
420
+ # Handle BP written as "112/74"
421
+ bp_match = re.search(r'(\d{2,3})\s*/\s*(\d{2,3})', line)
422
+ if bp_match and field in ("systolic_bp", "diastolic_bp"):
423
+ s, d = float(bp_match.group(1)), float(bp_match.group(2))
424
+ if 70 <= s <= 200: visit["systolic_bp"] = s
425
+ if 40 <= d <= 130: visit["diastolic_bp"] = d
426
+ continue
427
+
428
+ nums = find_numbers(line)
429
+ lo, hi = RANGES[field]
430
+ valid = [n for n in nums if lo <= n <= hi]
431
+ if valid:
432
+ visit[field] = valid[0]
433
+
434
+ return [visit] if visit else []
435
+
436
+
437
+ def score_visits(visits: List[dict]) -> int:
438
+ """Count total non-None extracted values across all visits."""
439
+ return sum(1 for v in visits for val in v.values() if val is not None)
440
+
441
+
442
+ def best_parse(raw_text: str) -> Tuple[List[dict], str]:
443
+ """Try all parsers and return the one that extracted the most data."""
444
+ candidates = [
445
+ (parse_table_format(raw_text), "row-per-field"),
446
+ (parse_column_format(raw_text), "column-per-visit"),
447
+ (parse_key_value_format(raw_text),"key-value"),
448
+ ]
449
+ return max(candidates, key=lambda x: score_visits(x[0]))
450
+
451
+
452
+ def extract_patient_id(raw_text: str) -> Optional[str]:
453
+ """Find common patient ID patterns in OCR text."""
454
+ patterns = [
455
+ r'PT[-\s]?\d{4}[-\s]?\d+',
456
+ r'Patient\s*(ID|No\.?|Number)[:\s]+([\w-]+)',
457
+ r'Reg(?:istration)?\s*(No\.?|#|:)\s*([\w-]+)',
458
+ r'ANC\s*(?:No\.?|#|:)?\s*([\w-]+)',
459
+ r'Card\s*(?:No\.?|#)[:\s]*([\w-]+)',
460
+ r'P/(\d+)',
461
+ r'MRN[:\s]+([\w-]+)',
462
+ ]
463
+ for pattern in patterns:
464
+ m = re.search(pattern, raw_text, re.IGNORECASE)
465
+ if m:
466
+ groups = [g for g in m.groups() if g]
467
+ return groups[-1] if groups else m.group(0)
468
+ return None
469
+
470
+
471
+ # ═══════════════════════════════════════════════════════════════════════════════
472
+ # MAIN ENDPOINT
473
+ # ═══════════════════════════════════════════════════════════════════════════════
474
+
475
+ @router.post("/extract-report", response_model=ExtractResponse)
476
+ async def extract_report(request: ExtractRequest):
477
+ """
478
+ POST /extract-report — Offline OCR extraction from prenatal report images.
479
+ Runs multiple preprocessing pipelines × OCR configs, returns best result.
480
+ """
481
+
482
+ # 1. Decode image
483
+ try:
484
+ image_bytes = base64.b64decode(request.image_base64)
485
+ pil_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
486
+ except Exception as e:
487
+ raise HTTPException(
488
+ status_code=400,
489
+ detail=f"Could not decode image: {e}"
490
+ )
491
+
492
+ # 2. Detect image type → get pipeline order
493
+ gray = to_gray(pil_image)
494
+ img_type = detect_image_type(gray, pil_image)
495
+ pipelines = PIPELINE_ORDERS.get(img_type, PIPELINE_ORDERS["standard"])
496
+
497
+ # 3. Run each pipeline → OCR → parse → score
498
+ best_visits = []
499
+ best_score = 0
500
+ best_text = ""
501
+ best_pipeline = "none"
502
+ best_parser = "none"
503
+ all_texts = []
504
+
505
+ for pipeline_name in pipelines:
506
+ try:
507
+ if pipeline_name == "color_form":
508
+ processed = pipeline_color_form(pil_image)
509
+ else:
510
+ fn = PIPELINE_FNS.get(pipeline_name, pipeline_standard)
511
+ processed = fn(to_gray(pil_image))
512
+ except Exception:
513
+ continue
514
+
515
+ try:
516
+ raw_text = run_ocr_best(processed)
517
+ except Exception:
518
+ continue
519
+
520
+ all_texts.append(raw_text[:200])
521
+
522
+ visits, parser_name = best_parse(raw_text)
523
+ score = score_visits(visits)
524
+
525
+ if score > best_score:
526
+ best_score = score
527
+ best_visits = visits
528
+ best_text = raw_text
529
+ best_pipeline = pipeline_name
530
+ best_parser = parser_name
531
+
532
+ # 4. Handle complete failure
533
+ if not best_visits or best_score == 0:
534
+ fallback_text = all_texts[0][:300] if all_texts else "No text extracted"
535
+ return ExtractResponse(
536
+ visits = [ExtractedVisit()],
537
+ patient_id = None,
538
+ notes = (
539
+ f"Image type detected: {img_type}. "
540
+ "No structured data could be extracted. "
541
+ "Likely causes: very blurry, pure handwriting, or unusual layout. "
542
+ f"Raw OCR text: {fallback_text}"
543
+ ),
544
+ confidence = 0.0,
545
+ raw_text = fallback_text,
546
+ )
547
+
548
+ # 5. Build ExtractedVisit objects
549
+ extracted = [
550
+ ExtractedVisit(
551
+ age = v.get("age"),
552
+ systolic_bp = v.get("systolic_bp"),
553
+ diastolic_bp = v.get("diastolic_bp"),
554
+ blood_sugar = v.get("blood_sugar"),
555
+ body_temp = v.get("body_temp"),
556
+ heart_rate = v.get("heart_rate"),
557
+ )
558
+ for v in best_visits
559
+ ]
560
+
561
+ total_possible = len(extracted) * 6
562
+ total_filled = sum(
563
+ 1 for v in extracted
564
+ for val in [v.age, v.systolic_bp, v.diastolic_bp,
565
+ v.blood_sugar, v.body_temp, v.heart_rate]
566
+ if val is not None
567
+ )
568
+ confidence = round(total_filled / max(total_possible, 1), 2)
569
+
570
+ notes = (
571
+ f"Image type: {img_type} | "
572
+ f"Pipeline: {best_pipeline} | "
573
+ f"Parser: {best_parser} | "
574
+ f"{total_filled}/{total_possible} fields extracted."
575
+ )
576
+
577
+ return ExtractResponse(
578
+ visits = extracted,
579
+ patient_id = extract_patient_id(best_text),
580
+ notes = notes,
581
+ confidence = confidence,
582
+ raw_text = best_text[:500],
583
+ )
api/main.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard -- FastAPI Server
3
+ Maternal risk prediction API with clinical safety net and explainability.
4
+ """
5
+
6
+ import torch
7
+ import numpy as np
8
+ import pickle
9
+ import os
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.responses import FileResponse
14
+
15
+ from api.schemas import PredictionRequest, PredictionResponse, AlertTier
16
+ from api.alert_logic import compute_alert_tier, generate_action_text
17
+ from src.model import MamaGuardMamba3
18
+ from src.explainability import explain_prediction
19
+ from api.extract_report import router as extract_router
20
+
21
+ # --- App setup ----------------------------------------------------------------
22
+ app = FastAPI(
23
+ title="SheGuard -- Maternal Risk API",
24
+ description=(
25
+ "Predicts maternal mortality risk from prenatal visit sequences. "
26
+ "Built with Mamba3 SSM for deployment in low-resource clinics."
27
+ ),
28
+ version="1.0.0",
29
+ )
30
+
31
+ app.include_router(extract_router)
32
+
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=["*"],
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ # ─── Model loading ────────────────────────────────────────────────────────────
41
+
42
+ MODEL_PATH = "models/mamaguard_mamba3.pt"
43
+ SCALER_PATH = "models/scaler.pkl"
44
+
45
+ model = None
46
+ scaler = None
47
+ device = "cuda" if torch.cuda.is_available() else "cpu"
48
+
49
+ FEATURE_ORDER = ['Age', 'SystolicBP', 'DiastolicBP', 'BS', 'BodyTemp', 'HeartRate']
50
+ FEATURE_DEFAULTS = {
51
+ 'Age': 30.0, 'SystolicBP': 120.0, 'DiastolicBP': 80.0,
52
+ 'BS': 7.5, 'BodyTemp': 36.8, 'HeartRate': 76.0
53
+ }
54
+
55
+
56
+ @app.on_event("startup")
57
+ async def load_model():
58
+ """Load model and scaler at server startup."""
59
+ global model, scaler
60
+
61
+ if not os.path.exists(MODEL_PATH):
62
+ print(f"WARNING: Model not found at {MODEL_PATH}. Run training first.")
63
+ return
64
+
65
+ model = MamaGuardMamba3(input_dim=6, d_model=64, n_layers=4, n_classes=3, d_state=32)
66
+ model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
67
+ model.to(device)
68
+ model.eval()
69
+
70
+ with open(SCALER_PATH, "rb") as f:
71
+ scaler = pickle.load(f)
72
+
73
+ print(f"MamaGuard model loaded on {device}")
74
+
75
+
76
+ # --- Routes -------------------------------------------------------------------
77
+
78
+ @app.get("/health")
79
+ async def health_check():
80
+ """Health check endpoint for load balancers."""
81
+ return {
82
+ "status": "healthy",
83
+ "model_loaded": model is not None,
84
+ "device": device,
85
+ "version": "1.0.0",
86
+ }
87
+
88
+
89
+ @app.post("/predict", response_model=PredictionResponse)
90
+ async def predict(request: PredictionRequest):
91
+ """
92
+ Process patient visit data and return risk prediction with explanation.
93
+ """
94
+
95
+ if model is None or scaler is None:
96
+ raise HTTPException(
97
+ status_code=503,
98
+ detail="Model not loaded. Contact system administrator."
99
+ )
100
+
101
+ # Prepare visit data
102
+ visits = request.visits
103
+ visit_map = {
104
+ 'Age': 'age', 'SystolicBP': 'systolic_bp', 'DiastolicBP': 'diastolic_bp',
105
+ 'BS': 'blood_sugar', 'BodyTemp': 'body_temp', 'HeartRate': 'heart_rate'
106
+ }
107
+
108
+ raw_array = []
109
+ missing_counts = []
110
+
111
+ for visit in visits:
112
+ row = []
113
+ missing = 0
114
+ for feat in FEATURE_ORDER:
115
+ attr = visit_map[feat]
116
+ val = getattr(visit, attr, None)
117
+ if val is None:
118
+ val = FEATURE_DEFAULTS[feat]
119
+ missing += 1
120
+ row.append(val)
121
+ raw_array.append(row)
122
+ missing_counts.append(missing)
123
+
124
+ raw_np = np.array(raw_array, dtype=np.float32)
125
+
126
+ # Data quality score
127
+ total_fields = len(visits) * len(FEATURE_ORDER)
128
+ missing_total = sum(missing_counts)
129
+ data_quality = round(1.0 - missing_total / total_fields, 3)
130
+
131
+ # Scale
132
+ scaled_np = scaler.transform(raw_np)
133
+
134
+ # Pad or truncate to SEQ_LEN=5
135
+ SEQ_LEN = 5
136
+ n = len(scaled_np)
137
+ if n < SEQ_LEN:
138
+ pad = np.zeros((SEQ_LEN - n, scaled_np.shape[1]), dtype=np.float32)
139
+ scaled_np = np.vstack([pad, scaled_np])
140
+ elif n > SEQ_LEN:
141
+ scaled_np = scaled_np[-SEQ_LEN:]
142
+
143
+ # Clinical safety net (hard WHO rules)
144
+ from api.alert_logic import apply_clinical_safety_net, AlertTier
145
+
146
+ visits_raw = [v.model_dump() for v in request.visits]
147
+ forced_tier, forced_reason = apply_clinical_safety_net(visits_raw)
148
+
149
+ # Run model and explain
150
+ explanation = explain_prediction(model, scaled_np, scaler, device)
151
+
152
+ if forced_tier is not None:
153
+ explanation["top_reasons"].insert(0, f"[WHO guideline] {forced_reason}")
154
+
155
+ # Alert tier
156
+ alert_tier, suppressed = compute_alert_tier(
157
+ probabilities = explanation["probabilities"],
158
+ patient_id = request.patient_id,
159
+ visit_importances= explanation["visit_importance"],
160
+ )
161
+
162
+ # Override with clinical rule minimum if needed
163
+ tier_order = {"GREEN": 0, "AMBER": 1, "RED": 2}
164
+ if forced_tier is not None:
165
+ if tier_order[forced_tier] > tier_order[alert_tier.value]:
166
+ alert_tier = AlertTier(forced_tier)
167
+ suppressed = False
168
+
169
+ # Action text
170
+ action, transfer_order = generate_action_text(
171
+ tier = alert_tier,
172
+ top_reasons = explanation["top_reasons"],
173
+ data_quality = data_quality,
174
+ staff_available = request.staff_available,
175
+ blood_units = request.blood_units,
176
+ )
177
+
178
+ return PredictionResponse(
179
+ patient_id = request.patient_id,
180
+ risk_level = explanation["risk_level"],
181
+ alert_tier = alert_tier,
182
+ confidence = explanation["confidence"],
183
+ data_quality = data_quality,
184
+ top_reasons = explanation["top_reasons"],
185
+ feature_importance= explanation["feature_importance"],
186
+ visit_importance = explanation["visit_importance"],
187
+ action_required = action,
188
+ transfer_order = transfer_order,
189
+ suppressed = suppressed,
190
+ probabilities = explanation["probabilities"],
191
+ )
192
+
193
+
194
+ @app.get("/stats")
195
+ async def get_stats():
196
+ """Returns patient assessment statistics."""
197
+ from api.alert_logic import _alert_history
198
+ total = len(_alert_history)
199
+ by_tier = {"GREEN": 0, "AMBER": 0, "RED": 0}
200
+ for v in _alert_history.values():
201
+ by_tier[v["tier"].value] += 1
202
+
203
+ return {
204
+ "patients_assessed": total,
205
+ "by_tier": by_tier,
206
+ "model_version": "SheGuard-Mamba3-v1",
207
+ }
208
+
209
+
210
+ # --- Dashboard static files ---------------------------------------------------
211
+
212
+ DASHBOARD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "dashboard")
213
+
214
+ if os.path.isdir(DASHBOARD_DIR):
215
+ app.mount("/static", StaticFiles(directory=DASHBOARD_DIR), name="dashboard-static")
216
+
217
+
218
+ @app.get("/")
219
+ async def serve_dashboard():
220
+ """Serve the dashboard HTML at root."""
221
+ index_path = os.path.join(DASHBOARD_DIR, "index.html")
222
+ if os.path.exists(index_path):
223
+ return FileResponse(index_path, media_type="text/html")
224
+ return {"status": "ok", "model_loaded": model is not None, "device": device}
api/schemas.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — API Schemas
3
+ Pydantic models for request/response validation.
4
+ """
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+ from typing import Optional, List
8
+ from enum import Enum
9
+
10
+
11
+ class RiskLevelEnum(str, Enum):
12
+ LOW = "Low risk"
13
+ MEDIUM = "Medium risk"
14
+ HIGH = "High risk"
15
+
16
+
17
+ class PrenatalVisit(BaseModel):
18
+ """One prenatal checkup reading."""
19
+ age: float = Field(..., ge=10, le=60, description="Patient age in years")
20
+ systolic_bp: float = Field(..., ge=70, le=200, description="Systolic blood pressure mmHg")
21
+ diastolic_bp: float = Field(..., ge=40, le=130, description="Diastolic blood pressure mmHg")
22
+ blood_sugar: Optional[float] = Field(None, ge=3.0, le=20.0, description="Blood glucose mmol/L")
23
+ body_temp: Optional[float] = Field(None, ge=35.0, le=42.0, description="Temperature °C")
24
+ heart_rate: Optional[float] = Field(None, ge=40, le=160, description="Heart rate bpm")
25
+ visit_date: Optional[str] = Field(None, description="ISO date of visit e.g. 2024-03-15")
26
+
27
+
28
+ class PredictionRequest(BaseModel):
29
+ """Request body for POST /predict."""
30
+ patient_id: str = Field(..., description="Clinic's own patient ID")
31
+ visits: List[PrenatalVisit] = Field(
32
+ ..., min_length=1, max_length=10,
33
+ description="Prenatal visit readings in chronological order (oldest first)"
34
+ )
35
+ staff_available: Optional[int] = Field(None, ge=0, description="Doctors on duty right now")
36
+ blood_units: Optional[int] = Field(None, ge=0, description="Units of O-negative blood in stock")
37
+ clinic_name: Optional[str] = Field(None, description="Clinic name for alert routing")
38
+
39
+ @field_validator("visits")
40
+ @classmethod
41
+ def at_least_one_visit(cls, v):
42
+ if len(v) < 1:
43
+ raise ValueError("At least 1 prenatal visit is required")
44
+ return v
45
+
46
+
47
+ class AlertTier(str, Enum):
48
+ GREEN = "GREEN" # < 65% high risk confidence
49
+ AMBER = "AMBER" # 65–85% — watch closely
50
+ RED = "RED" # > 85% + 2+ consecutive high-risk visits
51
+
52
+
53
+ class PredictionResponse(BaseModel):
54
+ """Response from POST /predict."""
55
+ patient_id: str
56
+ risk_level: RiskLevelEnum
57
+ alert_tier: AlertTier
58
+ confidence: float
59
+ data_quality: float
60
+ top_reasons: List[str]
61
+ feature_importance: dict
62
+ visit_importance: List[float]
63
+ action_required: str
64
+ transfer_order: Optional[str] = None
65
+ suppressed: bool = False
66
+ probabilities: dict
dashboard/app.js ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════════════════════════════
2
+ MamaGuard — Dashboard Application Logic
3
+ ══════════════════════════════════════════════════════════════════════════ */
4
+
5
+ const API_BASE = "";
6
+
7
+ /* ── State ─────────────────────────────────────────────────────────────── */
8
+
9
+ let visitCount = 0;
10
+ let currentImageBase64 = null;
11
+ let currentImageType = null;
12
+
13
+
14
+ /* ── Visit Management ──────────────────────────────────────────────────── */
15
+
16
+ function addVisit(prefill = {}) {
17
+ visitCount++;
18
+ const container = document.getElementById("visits-container");
19
+ const block = document.createElement("div");
20
+ block.className = "visit-block";
21
+ block.id = `visit-${visitCount}`;
22
+
23
+ const v = visitCount;
24
+ block.innerHTML = `
25
+ <h4>Visit ${v}
26
+ ${prefill.visit_date
27
+ ? `<span style="font-weight:normal;color:var(--text-muted);font-size:0.78rem;margin-left:8px;">${prefill.visit_date}</span>`
28
+ : ""}
29
+ </h4>
30
+ <div class="grid-3">
31
+ <div>
32
+ <label>Age *</label>
33
+ <input type="number" id="v${v}_age" placeholder="e.g. 28" min="10" max="60">
34
+ </div>
35
+ <div>
36
+ <label>Systolic BP * (mmHg)</label>
37
+ <input type="number" id="v${v}_sbp" placeholder="e.g. 118" min="70" max="200">
38
+ </div>
39
+ <div>
40
+ <label>Diastolic BP * (mmHg)</label>
41
+ <input type="number" id="v${v}_dbp" placeholder="e.g. 75" min="40" max="130">
42
+ </div>
43
+ <div>
44
+ <label>Blood sugar (mmol/L)</label>
45
+ <input type="number" id="v${v}_bs" placeholder="e.g. 7.2" min="3" max="20" step="0.1">
46
+ </div>
47
+ <div>
48
+ <label>Body temp (&deg;C)</label>
49
+ <input type="number" id="v${v}_bt" placeholder="e.g. 36.8" min="35" max="42" step="0.1">
50
+ </div>
51
+ <div>
52
+ <label>Heart rate (bpm)</label>
53
+ <input type="number" id="v${v}_hr" placeholder="e.g. 78" min="40" max="160">
54
+ </div>
55
+ </div>`;
56
+ container.appendChild(block);
57
+
58
+ if (Object.keys(prefill).length > 0) {
59
+ fillVisit(v, prefill);
60
+ }
61
+ }
62
+
63
+ function fillVisit(v, data) {
64
+ const fieldMap = {
65
+ age: `v${v}_age`,
66
+ systolic_bp: `v${v}_sbp`,
67
+ diastolic_bp: `v${v}_dbp`,
68
+ blood_sugar: `v${v}_bs`,
69
+ body_temp: `v${v}_bt`,
70
+ heart_rate: `v${v}_hr`,
71
+ };
72
+
73
+ for (const [key, inputId] of Object.entries(fieldMap)) {
74
+ const val = data[key];
75
+ const el = document.getElementById(inputId);
76
+ if (el && val !== null && val !== undefined) {
77
+ el.value = val;
78
+ el.classList.add("autofilled");
79
+ el.addEventListener("input", () => el.classList.remove("autofilled"), { once: true });
80
+ }
81
+ }
82
+ }
83
+
84
+
85
+ /* ── File Upload ───────────────────────────────────────────────────────── */
86
+
87
+ function handleFileSelect(input) {
88
+ const file = input.files[0];
89
+ if (!file) return;
90
+
91
+ const reader = new FileReader();
92
+ reader.onload = (e) => {
93
+ const dataUrl = e.target.result;
94
+ const [header, base64] = dataUrl.split(",");
95
+ currentImageBase64 = base64;
96
+ currentImageType = file.type || "image/jpeg";
97
+
98
+ document.getElementById("preview-img").src = dataUrl;
99
+ document.getElementById("preview-wrap").style.display = "block";
100
+ document.getElementById("btn-extract").style.display = "block";
101
+ document.getElementById("extract-status").className = "extract-status";
102
+ document.getElementById("extract-status").textContent = "";
103
+ };
104
+ reader.readAsDataURL(file);
105
+ }
106
+
107
+ function clearUpload() {
108
+ currentImageBase64 = null;
109
+ currentImageType = null;
110
+ document.getElementById("file-input").value = "";
111
+ document.getElementById("preview-wrap").style.display = "none";
112
+ document.getElementById("btn-extract").style.display = "none";
113
+ document.getElementById("extract-status").className = "extract-status";
114
+ document.getElementById("extract-status").textContent = "";
115
+ document.getElementById("confidence-strip").style.display = "none";
116
+ }
117
+
118
+
119
+ /* ── Drag & Drop ───────────────────────────────────────────────────────── */
120
+
121
+ document.addEventListener("DOMContentLoaded", () => {
122
+ const zone = document.getElementById("upload-zone");
123
+
124
+ zone.addEventListener("dragover", (e) => {
125
+ e.preventDefault();
126
+ zone.classList.add("drag-over");
127
+ });
128
+
129
+ zone.addEventListener("dragleave", () => {
130
+ zone.classList.remove("drag-over");
131
+ });
132
+
133
+ zone.addEventListener("drop", (e) => {
134
+ e.preventDefault();
135
+ zone.classList.remove("drag-over");
136
+ const file = e.dataTransfer.files[0];
137
+ if (file) {
138
+ const dt = new DataTransfer();
139
+ dt.items.add(file);
140
+ document.getElementById("file-input").files = dt.files;
141
+ handleFileSelect(document.getElementById("file-input"));
142
+ }
143
+ });
144
+
145
+ // Add first visit on load
146
+ addVisit();
147
+ });
148
+
149
+
150
+ /* ── Image Extraction ──────────────────────────────────────────────────── */
151
+
152
+ async function extractFromImage() {
153
+ if (!currentImageBase64) return;
154
+
155
+ const statusEl = document.getElementById("extract-status");
156
+ const extractBtn = document.getElementById("btn-extract");
157
+
158
+ statusEl.className = "extract-status loading";
159
+ statusEl.textContent = "Reading the report... (2-5 seconds)";
160
+ extractBtn.disabled = true;
161
+
162
+ try {
163
+ const resp = await fetch(`${API_BASE}/extract-report`, {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json" },
166
+ body: JSON.stringify({
167
+ image_base64: currentImageBase64,
168
+ image_type: currentImageType,
169
+ }),
170
+ });
171
+
172
+ if (!resp.ok) {
173
+ const err = await resp.json();
174
+ throw new Error(err.detail || `Server error ${resp.status}`);
175
+ }
176
+
177
+ const data = await resp.json();
178
+
179
+ if (data.patient_id) {
180
+ const pidField = document.getElementById("patient_id");
181
+ if (!pidField.value) {
182
+ pidField.value = data.patient_id;
183
+ pidField.classList.add("autofilled");
184
+ }
185
+ }
186
+
187
+ document.getElementById("visits-container").innerHTML = "";
188
+ visitCount = 0;
189
+
190
+ const visits = data.visits || [];
191
+ if (visits.length === 0) {
192
+ throw new Error("No visit data found in the image. Try a clearer photo.");
193
+ }
194
+
195
+ visits.forEach(visit => addVisit(visit));
196
+
197
+ const confPct = Math.round((data.confidence || 1.0) * 100);
198
+ const confColor = confPct >= 80 ? "var(--green)" : confPct >= 60 ? "var(--amber)" : "var(--red)";
199
+ const confEl = document.getElementById("confidence-strip");
200
+ confEl.style.display = "block";
201
+ confEl.innerHTML = `
202
+ <span style="color:${confColor};font-weight:600;">
203
+ Extraction confidence: ${confPct}%
204
+ </span>
205
+ ${confPct < 80 ? " -- please verify highlighted fields before submitting" : " -- all fields extracted successfully"}
206
+ ${data.notes ? `<br><span style="color:var(--text-muted);">${data.notes}</span>` : ""}
207
+ `;
208
+
209
+ statusEl.className = "extract-status success";
210
+ statusEl.textContent = `Extracted ${visits.length} visit${visits.length > 1 ? "s" : ""} -- highlighted fields are auto-filled. Verify before submitting.`;
211
+
212
+ } catch (err) {
213
+ statusEl.className = "extract-status error";
214
+ statusEl.textContent = `Extraction failed: ${err.message}`;
215
+ } finally {
216
+ extractBtn.disabled = false;
217
+ }
218
+ }
219
+
220
+
221
+ /* ── Prediction Submission ─────────────────────────────────────────────── */
222
+
223
+ function getVal(id) {
224
+ const v = document.getElementById(id)?.value;
225
+ return (v === "" || v === null || v === undefined) ? null : parseFloat(v);
226
+ }
227
+
228
+ async function submitPrediction() {
229
+ document.getElementById("result-card").style.display = "none";
230
+ document.getElementById("error-box").style.display = "none";
231
+ document.getElementById("loader").style.display = "block";
232
+
233
+ const patientId = document.getElementById("patient_id").value.trim();
234
+ if (!patientId) { showError("Please enter a Patient ID"); return; }
235
+
236
+ const visits = [];
237
+ for (let i = 1; i <= visitCount; i++) {
238
+ const age = getVal(`v${i}_age`);
239
+ const sbp = getVal(`v${i}_sbp`);
240
+ const dbp = getVal(`v${i}_dbp`);
241
+ if (age === null || sbp === null || dbp === null) continue;
242
+ visits.push({
243
+ age, systolic_bp: sbp, diastolic_bp: dbp,
244
+ blood_sugar: getVal(`v${i}_bs`),
245
+ body_temp: getVal(`v${i}_bt`),
246
+ heart_rate: getVal(`v${i}_hr`),
247
+ });
248
+ }
249
+
250
+ if (visits.length === 0) {
251
+ showError("Please fill in at least one visit (Age, Systolic BP, Diastolic BP are required)");
252
+ return;
253
+ }
254
+
255
+ const payload = {
256
+ patient_id: patientId,
257
+ visits,
258
+ staff_available: getVal("staff_available"),
259
+ blood_units: getVal("blood_units"),
260
+ };
261
+
262
+ try {
263
+ const resp = await fetch(`${API_BASE}/predict`, {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json" },
266
+ body: JSON.stringify(payload),
267
+ });
268
+ if (!resp.ok) {
269
+ const err = await resp.json();
270
+ throw new Error(err.detail || `Server error ${resp.status}`);
271
+ }
272
+ showResult(await resp.json());
273
+ } catch (err) {
274
+ showError(`Could not reach the server: ${err.message}. Is the API running?`);
275
+ } finally {
276
+ document.getElementById("loader").style.display = "none";
277
+ }
278
+ }
279
+
280
+
281
+ /* ── Result Display ────────────────────────────────────────────────────── */
282
+
283
+ function showResult(data) {
284
+ const tierClass = { GREEN: "alert-green", AMBER: "alert-amber", RED: "alert-red" };
285
+ const badgeClass = { GREEN: "badge-green", AMBER: "badge-amber", RED: "badge-red" };
286
+ const tierLabel = {
287
+ GREEN: "Low risk",
288
+ AMBER: "Medium risk -- monitor closely",
289
+ RED: "High risk -- refer immediately"
290
+ };
291
+
292
+ const card = document.getElementById("result-card");
293
+ const tier = data.alert_tier;
294
+
295
+ card.className = `card result ${tierClass[tier]}`;
296
+ card.style.display = "block";
297
+
298
+ document.getElementById("risk-badge").textContent = tierLabel[tier];
299
+ document.getElementById("risk-badge").className = `risk-badge ${badgeClass[tier]}`;
300
+
301
+ document.getElementById("confidence-text").textContent =
302
+ `Model confidence: ${(data.confidence * 100).toFixed(1)}%` +
303
+ (data.suppressed ? " (alert suppressed -- already sent within 48h)" : "");
304
+
305
+ const qPct = Math.round(data.data_quality * 100);
306
+ document.getElementById("quality-bar").style.width = `${qPct}%`;
307
+ document.getElementById("quality-bar").style.background = qPct >= 70 ? "var(--green)" : "var(--amber)";
308
+ document.getElementById("quality-text").textContent =
309
+ `Data quality: ${qPct}%` + (qPct < 70 ? " -- low confidence, collect missing readings" : " -- good");
310
+
311
+ document.getElementById("reasons-list").innerHTML =
312
+ data.top_reasons.map(r => `<div class="reason-item">${r}</div>`).join("");
313
+
314
+ document.getElementById("action-text").textContent = data.action_required;
315
+
316
+ const transferBox = document.getElementById("transfer-box");
317
+ if (data.transfer_order) {
318
+ document.getElementById("transfer-text").textContent = data.transfer_order;
319
+ transferBox.style.display = "block";
320
+ } else {
321
+ transferBox.style.display = "none";
322
+ }
323
+
324
+ card.scrollIntoView({ behavior: "smooth" });
325
+ }
326
+
327
+ function showError(msg) {
328
+ document.getElementById("loader").style.display = "none";
329
+ const box = document.getElementById("error-box");
330
+ box.textContent = msg;
331
+ box.style.display = "block";
332
+ }
dashboard/index.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="description" content="MamaGuard maternal risk assessment dashboard powered by Mamba3 AI">
8
+ <title>MamaGuard &mdash; Maternal Risk Assessment</title>
9
+ <link rel="stylesheet" href="/static/styles.css">
10
+ </head>
11
+
12
+ <body>
13
+ <div class="app-container">
14
+
15
+ <!-- Header -->
16
+ <header class="app-header">
17
+ <h1>MamaGuard</h1>
18
+ <p class="subtitle">Maternal risk assessment &mdash; powered by Mamba3 AI</p>
19
+ </header>
20
+
21
+ <!-- Patient Details -->
22
+ <section class="card" id="patient-section">
23
+ <h3 class="card-title">Patient details</h3>
24
+ <p class="card-desc">Fields marked * are required. Leave others blank if not measured.</p>
25
+
26
+ <label for="patient_id">Patient ID *</label>
27
+ <input type="text" id="patient_id" placeholder="e.g. PT-2024-0045" required>
28
+
29
+ <label for="staff_available">Clinic staff on duty</label>
30
+ <input type="number" id="staff_available" placeholder="e.g. 2" min="0">
31
+
32
+ <label for="blood_units">O-negative blood units in stock</label>
33
+ <input type="number" id="blood_units" placeholder="e.g. 4" min="0">
34
+ </section>
35
+
36
+ <!-- Upload Zone -->
37
+ <section class="card" id="upload-section">
38
+ <h3 class="card-title">Auto-fill from report photo</h3>
39
+ <p class="card-desc">
40
+ Upload a photo or scan of the prenatal record card.
41
+ AI will read it and fill in all visit fields automatically.
42
+ </p>
43
+
44
+ <div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
45
+ <span class="upload-icon">&#128247;</span>
46
+ <div class="upload-title">Tap to upload or drag a photo here</div>
47
+ <div class="upload-sub">JPEG, PNG, PDF &mdash; photo of paper report, printed form, or screenshot</div>
48
+ </div>
49
+
50
+ <input type="file" id="file-input" accept="image/*,.pdf" onchange="handleFileSelect(this)">
51
+
52
+ <div class="preview-wrap" id="preview-wrap">
53
+ <img id="preview-img" src="" alt="Report preview">
54
+ <button class="preview-remove" onclick="clearUpload()" title="Remove">&times;</button>
55
+ </div>
56
+
57
+ <div class="confidence-strip" id="confidence-strip"></div>
58
+
59
+ <button class="btn-extract" id="btn-extract" onclick="extractFromImage()">
60
+ Extract data from image
61
+ </button>
62
+
63
+ <div class="extract-status" id="extract-status"></div>
64
+ </section>
65
+
66
+ <!-- Visits -->
67
+ <section class="card" id="visits-section">
68
+ <h3 class="card-title">Prenatal visits (oldest first)</h3>
69
+ <p class="card-desc">Add visits manually, or use the photo upload above to auto-fill.</p>
70
+
71
+ <div id="visits-container"></div>
72
+ <button class="btn-add" onclick="addVisit()">+ Add another visit</button>
73
+ </section>
74
+
75
+ <!-- Submit -->
76
+ <button class="btn-primary" id="btn-submit" onclick="submitPrediction()">
77
+ Assess maternal risk
78
+ </button>
79
+
80
+ <div class="loader" id="loader">Analysing patient data...</div>
81
+ <div class="error-box" id="error-box"></div>
82
+
83
+ <!-- Results -->
84
+ <section class="card result" id="result-card">
85
+ <div id="alert-header">
86
+ <span id="risk-badge" class="risk-badge"></span>
87
+ <p id="confidence-text" style="font-size:0.84rem;color:var(--text-muted);margin-bottom:10px;"></p>
88
+ </div>
89
+
90
+ <p class="section-label">Data quality</p>
91
+ <div class="quality-bar-wrap">
92
+ <div class="quality-bar" id="quality-bar" style="background:var(--primary);width:0%"></div>
93
+ </div>
94
+ <p id="quality-text" class="section-label-sm"></p>
95
+
96
+ <p class="section-label" style="margin-top:14px;margin-bottom:6px;">Why this risk level:</p>
97
+ <div id="reasons-list"></div>
98
+
99
+ <p class="section-label" style="margin-top:14px;margin-bottom:6px;">Action required:</p>
100
+ <p id="action-text" style="font-size:0.92rem;line-height:1.5;"></p>
101
+
102
+ <div class="transfer-box" id="transfer-box" style="display:none;">
103
+ <strong>Transfer order:</strong>
104
+ <p id="transfer-text" style="margin-top:6px;"></p>
105
+ </div>
106
+ </section>
107
+
108
+ </div>
109
+
110
+ <script src="/static/app.js"></script>
111
+ </body>
112
+
113
+ </html>
dashboard/styles.css ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════════════════════════════
2
+ MamaGuard — Dashboard Styles
3
+ ══════════════════════════════════════════════════════════════════════════ */
4
+
5
+ /* ── Reset & Custom Properties ─────────────────────────────────────────── */
6
+
7
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
8
+
9
+ :root {
10
+ --primary: #8B2252;
11
+ --primary-light: #a83a6a;
12
+ --primary-pale: #f9eff4;
13
+ --primary-bg: #fdf8fb;
14
+ --accent: #5b1a6e;
15
+ --surface: rgba(255, 255, 255, 0.72);
16
+ --surface-solid: #ffffff;
17
+ --bg: #f3eef0;
18
+ --text: #2d2d2d;
19
+ --text-muted: #777;
20
+ --border: #e8dce2;
21
+ --green: #2e7d32;
22
+ --green-bg: #e8f5e9;
23
+ --amber: #e65100;
24
+ --amber-bg: #fff3e0;
25
+ --red: #c62828;
26
+ --red-bg: #ffebee;
27
+ --radius: 14px;
28
+ --radius-sm: 8px;
29
+ --shadow: 0 4px 24px rgba(139, 34, 82, 0.08);
30
+ --shadow-hover: 0 8px 32px rgba(139, 34, 82, 0.14);
31
+ --transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
32
+ }
33
+
34
+ *,
35
+ *::before,
36
+ *::after {
37
+ box-sizing: border-box;
38
+ margin: 0;
39
+ padding: 0;
40
+ }
41
+
42
+ body {
43
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
44
+ background: var(--bg);
45
+ color: var(--text);
46
+ line-height: 1.5;
47
+ min-height: 100vh;
48
+ }
49
+
50
+ /* ── Layout Container ──────────────────────────────────────────────────── */
51
+
52
+ .app-container {
53
+ max-width: 720px;
54
+ margin: 0 auto;
55
+ padding: 24px 16px 48px;
56
+ }
57
+
58
+ /* ── Header ────────────────────────────────────────────────────────────── */
59
+
60
+ .app-header {
61
+ text-align: center;
62
+ margin-bottom: 28px;
63
+ }
64
+
65
+ .app-header h1 {
66
+ font-size: 1.75rem;
67
+ font-weight: 700;
68
+ color: var(--primary);
69
+ letter-spacing: -0.02em;
70
+ }
71
+
72
+ .app-header .subtitle {
73
+ color: var(--text-muted);
74
+ font-size: 0.9rem;
75
+ margin-top: 4px;
76
+ font-weight: 400;
77
+ }
78
+
79
+ /* ── Cards (glassmorphism) ─────────────────────────────────────────────── */
80
+
81
+ .card {
82
+ background: var(--surface);
83
+ backdrop-filter: blur(12px);
84
+ -webkit-backdrop-filter: blur(12px);
85
+ border: 1px solid var(--border);
86
+ border-radius: var(--radius);
87
+ padding: 24px;
88
+ margin-bottom: 20px;
89
+ box-shadow: var(--shadow);
90
+ transition: box-shadow var(--transition);
91
+ animation: fadeSlideUp 0.4s ease both;
92
+ }
93
+
94
+ .card:hover {
95
+ box-shadow: var(--shadow-hover);
96
+ }
97
+
98
+ .card-title {
99
+ color: var(--primary);
100
+ font-size: 1rem;
101
+ font-weight: 600;
102
+ margin-bottom: 4px;
103
+ }
104
+
105
+ .card-desc {
106
+ font-size: 0.8rem;
107
+ color: var(--text-muted);
108
+ margin-bottom: 16px;
109
+ }
110
+
111
+ /* ── Form Elements ─────────────────────────────────────────────────────── */
112
+
113
+ label {
114
+ display: block;
115
+ font-weight: 500;
116
+ font-size: 0.82rem;
117
+ margin-bottom: 4px;
118
+ color: #555;
119
+ margin-top: 14px;
120
+ }
121
+
122
+ input {
123
+ width: 100%;
124
+ padding: 10px 14px;
125
+ border: 1.5px solid var(--border);
126
+ border-radius: var(--radius-sm);
127
+ font-family: inherit;
128
+ font-size: 0.92rem;
129
+ background: var(--surface-solid);
130
+ transition: border-color var(--transition), box-shadow var(--transition);
131
+ }
132
+
133
+ input:focus {
134
+ outline: none;
135
+ border-color: var(--primary);
136
+ box-shadow: 0 0 0 3px rgba(139, 34, 82, 0.12);
137
+ }
138
+
139
+ input::placeholder {
140
+ color: #bbb;
141
+ }
142
+
143
+ /* ── Visit Blocks ──────────────────────────────────────────────────────── */
144
+
145
+ .visit-block {
146
+ border: 1.5px solid var(--border);
147
+ border-radius: var(--radius-sm);
148
+ padding: 16px;
149
+ margin-top: 14px;
150
+ background: var(--primary-bg);
151
+ position: relative;
152
+ animation: fadeSlideUp 0.3s ease both;
153
+ }
154
+
155
+ .visit-block h4 {
156
+ color: var(--primary);
157
+ font-size: 0.88rem;
158
+ font-weight: 600;
159
+ margin-bottom: 10px;
160
+ }
161
+
162
+ .grid-3 {
163
+ display: grid;
164
+ grid-template-columns: 1fr 1fr 1fr;
165
+ gap: 12px;
166
+ }
167
+
168
+ /* ── Buttons ───────────────────────────────────────────────────────────── */
169
+
170
+ button {
171
+ font-family: inherit;
172
+ padding: 12px 24px;
173
+ border: none;
174
+ border-radius: var(--radius-sm);
175
+ cursor: pointer;
176
+ font-size: 0.95rem;
177
+ font-weight: 600;
178
+ transition: all var(--transition);
179
+ }
180
+
181
+ .btn-primary {
182
+ background: linear-gradient(135deg, var(--primary), var(--accent));
183
+ color: white;
184
+ width: 100%;
185
+ margin-top: 20px;
186
+ padding: 14px;
187
+ font-size: 1rem;
188
+ border-radius: var(--radius);
189
+ box-shadow: 0 4px 16px rgba(139, 34, 82, 0.25);
190
+ }
191
+
192
+ .btn-primary:hover {
193
+ transform: translateY(-1px);
194
+ box-shadow: 0 6px 24px rgba(139, 34, 82, 0.35);
195
+ }
196
+
197
+ .btn-primary:active {
198
+ transform: translateY(0);
199
+ }
200
+
201
+ .btn-add {
202
+ background: var(--primary-pale);
203
+ color: var(--primary);
204
+ margin-top: 10px;
205
+ font-size: 0.82rem;
206
+ padding: 10px 18px;
207
+ }
208
+
209
+ .btn-add:hover {
210
+ background: #f0dde6;
211
+ }
212
+
213
+ /* ── Upload Zone ───────────────────────────────────────────────────────── */
214
+
215
+ .upload-zone {
216
+ border: 2px dashed #c9a0b4;
217
+ border-radius: var(--radius);
218
+ padding: 32px 20px;
219
+ text-align: center;
220
+ cursor: pointer;
221
+ transition: all var(--transition);
222
+ background: var(--primary-bg);
223
+ margin-bottom: 16px;
224
+ }
225
+
226
+ .upload-zone:hover,
227
+ .upload-zone.drag-over {
228
+ border-color: var(--primary);
229
+ background: var(--primary-pale);
230
+ transform: scale(1.01);
231
+ }
232
+
233
+ .upload-zone .upload-icon {
234
+ font-size: 2.5rem;
235
+ display: block;
236
+ margin-bottom: 8px;
237
+ }
238
+
239
+ .upload-zone .upload-title {
240
+ font-weight: 600;
241
+ color: var(--primary);
242
+ font-size: 1rem;
243
+ margin-bottom: 4px;
244
+ }
245
+
246
+ .upload-zone .upload-sub {
247
+ font-size: 0.78rem;
248
+ color: var(--text-muted);
249
+ }
250
+
251
+ #file-input {
252
+ display: none;
253
+ }
254
+
255
+ /* ── Image Preview ─────────────────────────────────────────────────────── */
256
+
257
+ .preview-wrap {
258
+ display: none;
259
+ margin-top: 14px;
260
+ position: relative;
261
+ }
262
+
263
+ .preview-wrap img {
264
+ max-width: 100%;
265
+ max-height: 200px;
266
+ border-radius: var(--radius-sm);
267
+ border: 1.5px solid var(--border);
268
+ display: block;
269
+ margin: 0 auto;
270
+ }
271
+
272
+ .preview-remove {
273
+ position: absolute;
274
+ top: 6px;
275
+ right: 6px;
276
+ background: var(--red);
277
+ color: white;
278
+ border: none;
279
+ border-radius: 50%;
280
+ width: 28px;
281
+ height: 28px;
282
+ font-size: 0.8rem;
283
+ cursor: pointer;
284
+ display: flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ padding: 0;
288
+ transition: transform var(--transition);
289
+ }
290
+
291
+ .preview-remove:hover {
292
+ transform: scale(1.1);
293
+ }
294
+
295
+ /* ── Extract Button & Status ───────────────────────────────────────────── */
296
+
297
+ .btn-extract {
298
+ background: linear-gradient(135deg, var(--accent), #7b2d8e);
299
+ color: white;
300
+ width: 100%;
301
+ margin-top: 12px;
302
+ display: none;
303
+ font-size: 0.88rem;
304
+ padding: 12px;
305
+ border-radius: var(--radius-sm);
306
+ }
307
+
308
+ .btn-extract:hover {
309
+ transform: translateY(-1px);
310
+ }
311
+
312
+ .extract-status {
313
+ display: none;
314
+ margin-top: 12px;
315
+ padding: 12px 16px;
316
+ border-radius: var(--radius-sm);
317
+ font-size: 0.84rem;
318
+ }
319
+
320
+ .extract-status.loading {
321
+ background: #f3e5f5;
322
+ color: #6a1b9a;
323
+ display: block;
324
+ }
325
+
326
+ .extract-status.success {
327
+ background: var(--green-bg);
328
+ color: var(--green);
329
+ display: block;
330
+ }
331
+
332
+ .extract-status.error {
333
+ background: var(--red-bg);
334
+ color: var(--red);
335
+ display: block;
336
+ }
337
+
338
+ /* ── Autofilled Indicator ──────────────────────────────────────────────── */
339
+
340
+ .autofilled {
341
+ border-color: var(--primary) !important;
342
+ background: #fdf0f5 !important;
343
+ box-shadow: 0 0 0 2px rgba(139, 34, 82, 0.1) !important;
344
+ }
345
+
346
+ .confidence-strip {
347
+ display: none;
348
+ margin-top: 10px;
349
+ padding: 10px 14px;
350
+ background: var(--primary-pale);
351
+ border-radius: var(--radius-sm);
352
+ font-size: 0.8rem;
353
+ color: #555;
354
+ }
355
+
356
+ /* ── Results Card ──────────────────────────────────────────────────────── */
357
+
358
+ .result {
359
+ display: none;
360
+ }
361
+
362
+ .alert-green {
363
+ border-left: 5px solid var(--green);
364
+ background: rgba(232, 245, 233, 0.7);
365
+ backdrop-filter: blur(8px);
366
+ }
367
+
368
+ .alert-amber {
369
+ border-left: 5px solid var(--amber);
370
+ background: rgba(255, 243, 224, 0.7);
371
+ backdrop-filter: blur(8px);
372
+ }
373
+
374
+ .alert-red {
375
+ border-left: 5px solid var(--red);
376
+ background: rgba(255, 235, 238, 0.7);
377
+ backdrop-filter: blur(8px);
378
+ }
379
+
380
+ .risk-badge {
381
+ display: inline-block;
382
+ padding: 8px 20px;
383
+ border-radius: 24px;
384
+ font-weight: 700;
385
+ font-size: 1.05rem;
386
+ margin-bottom: 14px;
387
+ letter-spacing: -0.01em;
388
+ }
389
+
390
+ .badge-green {
391
+ background: #c8e6c9;
392
+ color: #1b5e20;
393
+ }
394
+
395
+ .badge-amber {
396
+ background: #ffe0b2;
397
+ color: #bf360c;
398
+ }
399
+
400
+ .badge-red {
401
+ background: #ffcdd2;
402
+ color: #b71c1c;
403
+ }
404
+
405
+ /* ── Quality Bar ───────────────────────────────────────────────────────── */
406
+
407
+ .quality-bar-wrap {
408
+ background: #eee;
409
+ border-radius: 4px;
410
+ height: 8px;
411
+ margin: 6px 0 10px;
412
+ overflow: hidden;
413
+ }
414
+
415
+ .quality-bar {
416
+ height: 8px;
417
+ border-radius: 4px;
418
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
419
+ }
420
+
421
+ /* ── Reason Items ──────────────────────────────────────────────────────── */
422
+
423
+ .reason-item {
424
+ padding: 8px 12px;
425
+ background: var(--primary-pale);
426
+ border-radius: var(--radius-sm);
427
+ margin-bottom: 6px;
428
+ font-size: 0.88rem;
429
+ line-height: 1.4;
430
+ }
431
+
432
+ /* ── Transfer Box ──────────────────────────────────────────────────────── */
433
+
434
+ .transfer-box {
435
+ background: var(--amber-bg);
436
+ border: 1.5px solid #ff9800;
437
+ border-radius: var(--radius-sm);
438
+ padding: 14px;
439
+ margin-top: 14px;
440
+ font-size: 0.88rem;
441
+ }
442
+
443
+ /* ── Loader & Error ────────────────────────────────────────────────────── */
444
+
445
+ .loader {
446
+ display: none;
447
+ text-align: center;
448
+ padding: 24px;
449
+ color: var(--primary);
450
+ font-size: 0.95rem;
451
+ font-weight: 500;
452
+ }
453
+
454
+ .error-box {
455
+ background: var(--red-bg);
456
+ border: 1.5px solid #ef9a9a;
457
+ border-radius: var(--radius-sm);
458
+ padding: 14px;
459
+ color: var(--red);
460
+ display: none;
461
+ margin-top: 16px;
462
+ font-size: 0.9rem;
463
+ }
464
+
465
+ /* ── Section Labels ────────────────────────────────────────────────────── */
466
+
467
+ .section-label {
468
+ font-size: 0.82rem;
469
+ font-weight: 600;
470
+ color: #555;
471
+ }
472
+
473
+ .section-label-sm {
474
+ font-size: 0.78rem;
475
+ color: var(--text-muted);
476
+ margin-bottom: 8px;
477
+ }
478
+
479
+ /* ── Animations ────────────────────────────────────────────────────────── */
480
+
481
+ @keyframes fadeSlideUp {
482
+ from {
483
+ opacity: 0;
484
+ transform: translateY(12px);
485
+ }
486
+ to {
487
+ opacity: 1;
488
+ transform: translateY(0);
489
+ }
490
+ }
491
+
492
+ .card:nth-child(1) { animation-delay: 0s; }
493
+ .card:nth-child(2) { animation-delay: 0.08s; }
494
+ .card:nth-child(3) { animation-delay: 0.16s; }
495
+
496
+ /* ── Responsive ────────────────────────────────────────────────────────── */
497
+
498
+ @media (max-width: 640px) {
499
+ .grid-3 {
500
+ grid-template-columns: 1fr 1fr;
501
+ gap: 10px;
502
+ }
503
+
504
+ .app-container {
505
+ padding: 16px 12px 32px;
506
+ }
507
+
508
+ .card {
509
+ padding: 18px;
510
+ }
511
+ }
512
+
513
+ @media (max-width: 400px) {
514
+ .grid-3 {
515
+ grid-template-columns: 1fr;
516
+ }
517
+ }
data/maternal_health.csv ADDED
@@ -0,0 +1,1015 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Age,SystolicBP,DiastolicBP,BS,BodyTemp,HeartRate,RiskLevel
2
+ 25,130,80,15,98,86,high risk
3
+ 35,140,90,13,98,70,high risk
4
+ 29,90,70,8,100,80,high risk
5
+ 30,140,85,7,98,70,high risk
6
+ 35,120,60,6.1,98,76,low risk
7
+ 23,140,80,7.01,98,70,high risk
8
+ 23,130,70,7.01,98,78,mid risk
9
+ 35,85,60,11,102,86,high risk
10
+ 32,120,90,6.9,98,70,mid risk
11
+ 42,130,80,18,98,70,high risk
12
+ 23,90,60,7.01,98,76,low risk
13
+ 19,120,80,7,98,70,mid risk
14
+ 25,110,89,7.01,98,77,low risk
15
+ 20,120,75,7.01,100,70,mid risk
16
+ 48,120,80,11,98,88,mid risk
17
+ 15,120,80,7.01,98,70,low risk
18
+ 50,140,90,15,98,90,high risk
19
+ 25,140,100,7.01,98,80,high risk
20
+ 30,120,80,6.9,101,76,mid risk
21
+ 10,70,50,6.9,98,70,low risk
22
+ 40,140,100,18,98,90,high risk
23
+ 50,140,80,6.7,98,70,mid risk
24
+ 21,90,65,7.5,98,76,low risk
25
+ 18,90,60,7.5,98,70,low risk
26
+ 21,120,80,7.5,98,76,low risk
27
+ 16,100,70,7.2,98,80,low risk
28
+ 19,120,75,7.2,98,66,low risk
29
+ 22,100,65,7.2,98,70,low risk
30
+ 49,120,90,7.2,98,77,low risk
31
+ 28,90,60,7.2,98,82,low risk
32
+ 20,100,90,7.1,98,88,low risk
33
+ 23,100,85,7.1,98,66,low risk
34
+ 22,120,90,7.1,98,82,low risk
35
+ 21,120,80,7.1,98,77,low risk
36
+ 21,75,50,6.1,98,70,low risk
37
+ 12,95,60,6.1,102,60,low risk
38
+ 60,120,80,6.1,98,75,low risk
39
+ 55,100,65,6.1,98,66,low risk
40
+ 45,120,95,6.1,98,66,low risk
41
+ 35,100,70,6.1,98,66,low risk
42
+ 22,120,85,6.1,98,88,low risk
43
+ 23,120,90,6.1,98,60,low risk
44
+ 25,90,70,6.1,98,80,low risk
45
+ 30,120,80,6.1,98,70,low risk
46
+ 23,120,90,6.1,98,70,low risk
47
+ 32,120,90,7.5,98,70,low risk
48
+ 42,120,80,7.5,98,70,low risk
49
+ 23,90,60,7.5,98,76,low risk
50
+ 15,76,49,7.5,98,77,low risk
51
+ 15,120,80,7,98,70,low risk
52
+ 25,120,80,7,98,66,low risk
53
+ 22,100,65,7,98,80,low risk
54
+ 35,100,70,7,98,60,low risk
55
+ 19,120,85,7,98,60,low risk
56
+ 60,90,65,7,98,77,low risk
57
+ 23,120,90,6.7,98,70,low risk
58
+ 32,120,90,6.4,98,70,low risk
59
+ 42,120,80,6.4,98,70,low risk
60
+ 23,90,60,6.4,98,76,low risk
61
+ 15,76,49,6.4,98,77,low risk
62
+ 15,120,80,7.2,98,70,low risk
63
+ 15,80,60,7,98,80,low risk
64
+ 12,95,60,7.2,98,77,low risk
65
+ 29,90,70,6.7,98,80,mid risk
66
+ 31,120,60,6.1,98,76,mid risk
67
+ 29,130,70,6.7,98,78,mid risk
68
+ 17,85,60,9,102,86,mid risk
69
+ 19,120,80,7,98,70,mid risk
70
+ 20,110,60,7,100,70,mid risk
71
+ 32,120,65,6,101,76,mid risk
72
+ 26,85,60,6,101,86,mid risk
73
+ 29,130,70,7.7,98,78,mid risk
74
+ 19,120,80,7,98,70,mid risk
75
+ 54,130,70,12,98,67,mid risk
76
+ 44,120,90,16,98,80,mid risk
77
+ 23,130,70,6.9,98,70,mid risk
78
+ 22,85,60,6.9,98,76,mid risk
79
+ 55,120,90,12,98,70,mid risk
80
+ 35,120,80,6.9,98,78,mid risk
81
+ 21,90,60,6.9,98,86,mid risk
82
+ 16,90,65,6.9,98,76,mid risk
83
+ 33,115,65,7,98,70,mid risk
84
+ 12,95,60,6.9,98,65,mid risk
85
+ 28,120,90,6.9,98,70,mid risk
86
+ 21,90,65,6.9,98,76,mid risk
87
+ 18,90,60,6.9,98,70,mid risk
88
+ 21,120,80,6.9,98,76,mid risk
89
+ 16,100,70,6.9,98,80,mid risk
90
+ 19,120,75,6.9,98,66,mid risk
91
+ 23,100,85,6.9,98,66,mid risk
92
+ 22,120,90,7.8,98,82,mid risk
93
+ 60,120,85,15,98,60,mid risk
94
+ 13,90,65,7.8,101,80,mid risk
95
+ 23,120,90,7.8,98,60,mid risk
96
+ 28,115,60,7.8,101,86,mid risk
97
+ 50,120,80,7.8,98,70,mid risk
98
+ 29,130,70,7.8,98,78,mid risk
99
+ 19,120,80,7,98,70,mid risk
100
+ 19,120,85,7.8,98,60,mid risk
101
+ 60,90,65,6.8,98,77,mid risk
102
+ 55,120,90,6.8,98,66,mid risk
103
+ 25,120,80,6.8,98,66,mid risk
104
+ 48,140,90,15,98,90,high risk
105
+ 25,140,100,6.8,98,80,high risk
106
+ 23,140,90,6.8,98,70,high risk
107
+ 34,85,60,11,102,86,high risk
108
+ 50,140,90,15,98,90,high risk
109
+ 25,140,100,6.8,98,80,high risk
110
+ 42,140,100,18,98,90,high risk
111
+ 32,140,100,7.9,98,78,high risk
112
+ 50,140,95,17,98,60,high risk
113
+ 38,135,60,7.9,101,86,high risk
114
+ 39,90,70,9,98,80,high risk
115
+ 30,140,100,15,98,70,high risk
116
+ 63,140,90,15,98,90,high risk
117
+ 25,140,100,7.9,98,80,high risk
118
+ 30,120,80,7.9,101,76,high risk
119
+ 55,140,100,18,98,90,high risk
120
+ 32,140,100,7.9,98,78,high risk
121
+ 30,140,100,15,98,70,high risk
122
+ 48,120,80,11,98,88,high risk
123
+ 49,140,90,15,98,90,high risk
124
+ 25,140,100,7.5,98,80,high risk
125
+ 40,160,100,19,98,77,high risk
126
+ 32,140,90,18,98,88,high risk
127
+ 35,140,100,7.5,98,66,high risk
128
+ 54,140,100,15,98,66,high risk
129
+ 55,140,95,19,98,77,high risk
130
+ 29,120,70,9,98,80,high risk
131
+ 48,120,80,11,98,88,high risk
132
+ 40,160,100,19,98,77,high risk
133
+ 32,140,90,18,98,88,high risk
134
+ 35,140,100,7.5,98,66,high risk
135
+ 54,140,100,15,98,66,high risk
136
+ 40,120,95,11,98,80,high risk
137
+ 22,90,60,7.5,102,60,high risk
138
+ 40,120,85,15,98,60,high risk
139
+ 55,140,95,19,98,77,high risk
140
+ 50,130,100,16,98,75,high risk
141
+ 18,120,80,6.9,102,76,mid risk
142
+ 32,140,100,6.9,98,78,high risk
143
+ 17,90,60,6.9,101,76,mid risk
144
+ 17,90,63,6.9,101,70,mid risk
145
+ 25,120,90,6.7,101,80,mid risk
146
+ 17,120,80,6.7,102,76,mid risk
147
+ 14,90,65,7,101,70,high risk
148
+ 15,80,60,6.7,98,80,low risk
149
+ 15,100,65,6.7,98,76,low risk
150
+ 12,95,60,6.7,98,77,low risk
151
+ 37,120,90,11,98,88,high risk
152
+ 18,100,70,6.7,98,76,low risk
153
+ 21,100,85,6.7,98,70,low risk
154
+ 17,110,75,12,101,76,high risk
155
+ 25,120,90,7.5,98,80,low risk
156
+ 23,85,65,7.5,98,70,low risk
157
+ 12,95,60,7.5,98,65,low risk
158
+ 28,120,90,7.5,98,70,low risk
159
+ 40,120,90,12,98,80,high risk
160
+ 55,129,85,7.5,98,88,low risk
161
+ 25,100,90,7.5,98,76,low risk
162
+ 35,120,80,7.5,98,80,low risk
163
+ 21,90,65,7.5,98,76,low risk
164
+ 18,90,60,7.5,98,70,low risk
165
+ 21,120,80,7.5,98,76,low risk
166
+ 16,100,70,7.2,98,80,low risk
167
+ 19,120,75,7.2,98,66,low risk
168
+ 40,160,100,19,98,77,high risk
169
+ 32,140,90,18,98,88,high risk
170
+ 22,100,65,7.2,98,70,low risk
171
+ 49,120,90,7.2,98,77,low risk
172
+ 28,90,60,7.2,98,82,low risk
173
+ 12,90,60,7.9,102,66,high risk
174
+ 20,100,90,7.1,98,88,low risk
175
+ 23,100,85,7.1,98,66,low risk
176
+ 22,120,90,7.1,98,82,low risk
177
+ 21,120,80,7.1,98,77,low risk
178
+ 35,140,100,8,98,66,high risk
179
+ 54,140,100,15,98,66,high risk
180
+ 40,120,95,11,98,80,high risk
181
+ 21,75,50,6.1,98,70,low risk
182
+ 12,95,60,6.1,102,60,low risk
183
+ 60,120,85,15,98,60,high risk
184
+ 55,140,95,19,98,77,high risk
185
+ 50,130,100,16,98,75,high risk
186
+ 60,120,80,6.1,98,75,low risk
187
+ 55,100,65,6.1,98,66,low risk
188
+ 45,120,95,6.1,98,66,low risk
189
+ 35,100,70,6.1,98,66,low risk
190
+ 22,120,85,6.1,98,88,low risk
191
+ 13,90,65,7.9,101,80,mid risk
192
+ 23,120,90,6.1,98,60,low risk
193
+ 17,90,65,6.1,103,67,high risk
194
+ 28,83,60,8,101,86,high risk
195
+ 50,120,80,15,98,70,high risk
196
+ 25,90,70,6.1,98,80,low risk
197
+ 30,120,80,6.1,98,70,low risk
198
+ 31,120,60,6.1,98,76,mid risk
199
+ 23,120,90,6.1,98,70,low risk
200
+ 29,130,70,6.1,98,78,mid risk
201
+ 17,85,60,9,102,86,high risk
202
+ 32,120,90,7.5,98,70,low risk
203
+ 42,120,80,7.5,98,70,low risk
204
+ 23,90,60,7.5,98,76,low risk
205
+ 19,120,80,7,98,70,mid risk
206
+ 15,76,49,7.5,98,77,low risk
207
+ 33,120,75,10,98,70,high risk
208
+ 48,120,80,11,98,88,high risk
209
+ 15,120,80,7,98,70,low risk
210
+ 25,120,80,7,98,66,low risk
211
+ 22,100,65,7,98,80,low risk
212
+ 50,140,95,17,98,60,high risk
213
+ 35,100,70,7,98,60,low risk
214
+ 19,120,85,7,98,60,low risk
215
+ 60,90,65,7,98,77,low risk
216
+ 28,85,60,9,101,86,mid risk
217
+ 50,140,80,6.7,98,70,mid risk
218
+ 29,90,70,6.7,98,80,mid risk
219
+ 30,140,100,15,98,70,high risk
220
+ 31,120,60,6.1,98,76,mid risk
221
+ 23,120,90,6.7,98,70,low risk
222
+ 29,130,70,6.7,98,78,mid risk
223
+ 17,85,60,9,102,86,mid risk
224
+ 32,120,90,6.4,98,70,low risk
225
+ 42,120,80,6.4,98,70,low risk
226
+ 23,90,60,6.4,98,76,low risk
227
+ 19,120,80,7,98,70,mid risk
228
+ 15,76,49,6.4,98,77,low risk
229
+ 29,120,75,7.2,100,70,high risk
230
+ 48,120,80,11,98,88,high risk
231
+ 15,120,80,7.2,98,70,low risk
232
+ 50,140,90,15,98,77,high risk
233
+ 25,140,100,7.2,98,80,high risk
234
+ 55,140,80,7.2,101,76,high risk
235
+ 20,110,60,7,100,70,mid risk
236
+ 40,140,100,18,98,77,high risk
237
+ 28,120,80,9,102,76,high risk
238
+ 32,140,100,8,98,70,high risk
239
+ 17,90,60,11,101,78,high risk
240
+ 17,90,63,8,101,70,high risk
241
+ 25,120,90,12,101,80,high risk
242
+ 17,120,80,7,102,76,high risk
243
+ 19,90,65,11,101,70,high risk
244
+ 15,80,60,7,98,80,low risk
245
+ 32,120,65,6,101,76,mid risk
246
+ 12,95,60,7.2,98,77,low risk
247
+ 37,120,90,11,98,88,high risk
248
+ 18,100,70,6.8,98,76,low risk
249
+ 21,100,85,6.9,98,70,low risk
250
+ 17,110,75,13,101,76,high risk
251
+ 25,120,90,15,98,80,high risk
252
+ 10,85,65,6.9,98,70,low risk
253
+ 12,95,60,6.9,98,65,low risk
254
+ 28,120,90,6.9,98,70,low risk
255
+ 40,120,90,6.9,98,80,low risk
256
+ 55,110,85,6.9,98,88,low risk
257
+ 25,100,90,6.9,98,76,low risk
258
+ 35,120,80,6.9,98,80,low risk
259
+ 21,90,65,6.9,98,76,low risk
260
+ 18,90,60,6.9,98,70,low risk
261
+ 21,120,80,6.9,98,76,low risk
262
+ 16,100,70,6.9,98,80,low risk
263
+ 19,120,75,6.9,98,66,low risk
264
+ 40,160,100,19,98,77,high risk
265
+ 32,140,90,18,98,88,high risk
266
+ 22,100,65,6.9,98,70,low risk
267
+ 49,120,90,6.9,98,77,low risk
268
+ 28,90,60,6.9,98,82,low risk
269
+ 12,90,60,8,102,66,high risk
270
+ 20,100,90,7,98,88,low risk
271
+ 23,100,85,7,98,66,low risk
272
+ 22,120,90,7,98,82,low risk
273
+ 21,120,80,7,98,77,low risk
274
+ 35,140,100,9,98,66,high risk
275
+ 54,140,100,15,98,66,high risk
276
+ 40,120,95,11,98,80,high risk
277
+ 21,75,50,7.7,98,60,low risk
278
+ 12,90,60,11,102,60,high risk
279
+ 60,120,85,15,98,60,high risk
280
+ 55,140,95,19,98,77,high risk
281
+ 50,130,100,16,98,76,high risk
282
+ 60,120,80,7.7,98,75,low risk
283
+ 55,100,65,7.7,98,66,low risk
284
+ 45,120,95,7.7,98,66,low risk
285
+ 35,100,70,7.7,98,66,low risk
286
+ 22,120,85,7.7,98,88,low risk
287
+ 13,90,65,9,101,80,high risk
288
+ 23,120,90,7.7,98,60,low risk
289
+ 17,90,65,7.7,103,67,high risk
290
+ 26,85,60,6,101,86,mid risk
291
+ 50,120,80,7.7,98,70,low risk
292
+ 19,90,70,7.7,98,80,low risk
293
+ 30,120,80,7.7,98,70,low risk
294
+ 31,120,60,6.1,98,76,low risk
295
+ 23,120,80,7.7,98,70,low risk
296
+ 29,130,70,7.7,98,78,mid risk
297
+ 17,85,60,6.3,102,86,high risk
298
+ 32,120,90,7.7,98,70,low risk
299
+ 42,120,80,7.7,98,70,low risk
300
+ 23,90,60,7.7,98,76,low risk
301
+ 19,120,80,7,98,70,mid risk
302
+ 15,75,49,7.7,98,77,low risk
303
+ 40,120,75,7.7,98,70,high risk
304
+ 48,120,80,11,98,88,high risk
305
+ 15,120,80,7.7,98,70,low risk
306
+ 25,120,80,7.7,98,66,low risk
307
+ 22,100,65,6.9,98,80,low risk
308
+ 12,120,95,6.9,98,60,low risk
309
+ 35,100,70,6.9,98,60,low risk
310
+ 19,120,85,6.9,98,60,low risk
311
+ 60,90,65,6.9,98,77,low risk
312
+ 55,120,90,6.9,98,76,low risk
313
+ 35,90,65,6.9,98,75,low risk
314
+ 51,85,60,6.9,98,66,low risk
315
+ 62,120,80,6.9,98,66,low risk
316
+ 25,90,70,6.9,98,66,low risk
317
+ 21,120,80,6.9,98,88,low risk
318
+ 22,120,60,15,98,80,high risk
319
+ 55,120,90,18,98,60,high risk
320
+ 54,130,70,12,98,67,mid risk
321
+ 35,85,60,19,98,86,high risk
322
+ 43,120,90,18,98,70,high risk
323
+ 12,120,80,6.9,98,80,low risk
324
+ 65,90,60,6.9,98,70,low risk
325
+ 60,120,80,6.9,98,76,low risk
326
+ 25,120,90,6.9,98,70,low risk
327
+ 22,90,65,6.9,98,78,low risk
328
+ 66,85,60,6.9,98,86,low risk
329
+ 56,120,80,13,98,70,high risk
330
+ 35,90,70,6.9,98,70,low risk
331
+ 43,120,80,15,98,76,high risk
332
+ 35,120,60,6.9,98,70,low risk
333
+ 44,120,90,16,98,80,mid risk
334
+ 23,130,70,6.9,98,70,mid risk
335
+ 22,85,60,6.9,98,76,mid risk
336
+ 55,120,90,12,98,70,mid risk
337
+ 35,120,80,6.9,98,78,mid risk
338
+ 21,90,60,6.9,98,86,mid risk
339
+ 45,120,80,6.9,103,70,low risk
340
+ 70,85,60,6.9,102,70,low risk
341
+ 65,120,90,6.9,103,76,low risk
342
+ 55,120,80,6.9,102,80,low risk
343
+ 45,90,60,18,101,70,high risk
344
+ 22,120,80,6.9,103,76,low risk
345
+ 16,90,65,6.9,98,76,mid risk
346
+ 12,95,60,6.9,98,77,low risk
347
+ 37,120,90,11,98,88,high risk
348
+ 18,100,70,6.9,98,76,low risk
349
+ 21,100,85,6.9,98,70,low risk
350
+ 17,110,75,6.9,101,76,high risk
351
+ 25,120,90,6.9,98,80,low risk
352
+ 33,115,65,7,98,70,mid risk
353
+ 12,95,60,6.9,98,65,mid risk
354
+ 28,120,90,6.9,98,70,mid risk
355
+ 40,120,90,6.9,98,80,high risk
356
+ 55,110,85,6.9,98,88,high risk
357
+ 25,100,90,6.9,98,76,high risk
358
+ 35,120,80,6.9,98,80,high risk
359
+ 21,90,65,6.9,98,76,mid risk
360
+ 18,90,60,6.9,98,70,mid risk
361
+ 21,120,80,6.9,98,76,mid risk
362
+ 16,100,70,6.9,98,80,mid risk
363
+ 19,120,75,6.9,98,66,mid risk
364
+ 40,160,100,19,98,77,high risk
365
+ 32,140,90,18,98,88,high risk
366
+ 23,100,85,6.9,98,66,mid risk
367
+ 22,120,90,7.8,98,82,mid risk
368
+ 21,120,80,7.8,98,77,low risk
369
+ 35,140,100,7.8,98,66,high risk
370
+ 54,140,100,15,98,66,high risk
371
+ 40,120,95,11,98,80,high risk
372
+ 21,75,50,7.8,98,60,low risk
373
+ 12,90,60,7.8,102,60,high risk
374
+ 60,120,85,15,98,60,mid risk
375
+ 55,140,95,19,98,77,high risk
376
+ 50,130,100,16,98,75,high risk
377
+ 60,120,80,7.8,98,75,high risk
378
+ 55,100,65,7.8,98,66,low risk
379
+ 45,120,95,7.8,98,66,low risk
380
+ 35,100,70,7.8,98,66,low risk
381
+ 22,120,85,7.8,98,88,low risk
382
+ 13,90,65,7.8,101,80,mid risk
383
+ 23,120,90,7.8,98,60,mid risk
384
+ 17,90,65,7.8,103,67,high risk
385
+ 28,115,60,7.8,101,86,mid risk
386
+ 50,120,80,7.8,98,70,mid risk
387
+ 19,90,70,7.8,98,80,low risk
388
+ 30,120,80,7.8,98,70,low risk
389
+ 31,120,60,6.1,98,76,low risk
390
+ 23,120,70,7.8,98,70,low risk
391
+ 29,130,70,7.8,98,78,mid risk
392
+ 17,85,69,7.8,102,86,high risk
393
+ 32,120,90,7.8,98,70,low risk
394
+ 42,120,80,7.8,98,70,low risk
395
+ 23,90,60,7.8,98,76,low risk
396
+ 19,120,80,7,98,70,mid risk
397
+ 15,76,49,7.8,98,77,low risk
398
+ 20,120,75,7.8,98,70,low risk
399
+ 48,120,80,11,98,88,high risk
400
+ 15,120,80,7.8,98,70,low risk
401
+ 25,120,80,7.8,98,66,low risk
402
+ 22,100,65,7.8,98,80,low risk
403
+ 12,120,95,7.8,98,60,low risk
404
+ 35,100,70,7.8,98,60,low risk
405
+ 19,120,85,7.8,98,60,mid risk
406
+ 60,90,65,6.8,98,77,mid risk
407
+ 55,120,90,6.8,98,66,mid risk
408
+ 25,120,80,6.8,98,66,mid risk
409
+ 22,100,65,6.8,98,88,low risk
410
+ 12,120,95,6.8,98,60,mid risk
411
+ 35,100,70,6.8,98,60,mid risk
412
+ 19,120,90,6.8,98,60,mid risk
413
+ 60,90,65,6.8,98,77,mid risk
414
+ 55,120,90,6.8,98,78,low risk
415
+ 50,130,80,16,102,76,mid risk
416
+ 27,120,90,6.8,102,68,mid risk
417
+ 60,140,90,12,98,77,high risk
418
+ 55,100,70,6.8,101,80,mid risk
419
+ 60,140,80,16,98,66,high risk
420
+ 12,120,90,6.8,98,80,mid risk
421
+ 17,140,100,6.8,103,80,high risk
422
+ 60,120,80,6.8,98,77,mid risk
423
+ 22,100,65,6.8,98,88,low risk
424
+ 36,140,100,6.8,102,76,high risk
425
+ 22,90,60,6.8,98,77,low risk
426
+ 25,120,100,6.8,98,60,mid risk
427
+ 35,100,60,15,98,80,high risk
428
+ 40,140,100,13,101,66,high risk
429
+ 27,120,70,6.8,98,77,low risk
430
+ 36,140,100,6.8,102,76,high risk
431
+ 22,90,60,6.8,98,77,mid risk
432
+ 25,120,100,6.8,98,60,low risk
433
+ 35,100,60,15,98,80,high risk
434
+ 40,140,100,13,101,66,high risk
435
+ 27,120,70,6.8,98,77,low risk
436
+ 27,120,70,6.8,98,77,low risk
437
+ 65,130,80,15,98,86,high risk
438
+ 35,140,80,13,98,70,high risk
439
+ 29,90,70,10,98,80,high risk
440
+ 30,120,80,6.8,98,70,mid risk
441
+ 35,120,60,6.1,98,76,mid risk
442
+ 23,140,90,6.8,98,70,high risk
443
+ 23,130,70,6.8,98,78,mid risk
444
+ 35,85,60,11,102,86,high risk
445
+ 32,120,90,6.8,98,70,low risk
446
+ 43,130,80,18,98,70,mid risk
447
+ 23,99,60,6.8,98,76,low risk
448
+ 19,120,80,7,98,70,mid risk
449
+ 15,76,49,6.8,98,77,low risk
450
+ 30,120,75,6.8,98,70,mid risk
451
+ 48,120,80,11,98,88,high risk
452
+ 15,120,80,6.8,98,70,low risk
453
+ 48,140,90,15,98,90,high risk
454
+ 25,140,100,6.8,98,80,high risk
455
+ 29,100,70,6.8,98,80,low risk
456
+ 32,120,80,6.8,98,70,mid risk
457
+ 35,120,60,6.1,98,76,low risk
458
+ 23,140,90,6.8,98,70,high risk
459
+ 23,130,70,6.8,98,78,mid risk
460
+ 34,85,60,11,102,86,high risk
461
+ 32,120,90,6.8,98,70,low risk
462
+ 42,130,80,18,98,70,mid risk
463
+ 23,90,60,6.8,98,76,low risk
464
+ 19,120,80,7,98,70,mid risk
465
+ 15,76,49,6.8,98,77,low risk
466
+ 20,120,75,6.8,98,70,low risk
467
+ 48,120,80,11,98,88,low risk
468
+ 15,120,80,6.8,98,70,low risk
469
+ 50,140,90,15,98,90,high risk
470
+ 25,140,100,6.8,98,80,high risk
471
+ 30,120,80,6.8,101,76,low risk
472
+ 31,110,90,6.8,100,70,mid risk
473
+ 42,140,100,18,98,90,high risk
474
+ 18,120,80,6.8,102,76,low risk
475
+ 32,140,100,7.9,98,78,high risk
476
+ 17,90,60,7.9,101,76,low risk
477
+ 19,120,80,7,98,70,mid risk
478
+ 15,76,49,7.9,98,77,low risk
479
+ 19,120,75,7.9,98,70,low risk
480
+ 48,120,80,11,98,88,low risk
481
+ 15,120,80,7.9,98,70,low risk
482
+ 25,120,80,7.9,98,66,mid risk
483
+ 22,100,65,7.9,98,80,low risk
484
+ 50,140,95,17,98,60,high risk
485
+ 35,100,70,7.9,98,60,low risk
486
+ 19,120,85,7.9,98,60,low risk
487
+ 60,90,65,7.9,98,77,low risk
488
+ 38,135,60,7.9,101,86,high risk
489
+ 50,120,80,7.9,98,70,low risk
490
+ 39,90,70,9,98,80,high risk
491
+ 30,140,100,15,98,70,high risk
492
+ 31,120,60,6.1,98,76,mid risk
493
+ 23,120,90,7.9,98,70,mid risk
494
+ 29,130,70,7.9,98,78,mid risk
495
+ 17,85,60,7.9,102,86,low risk
496
+ 32,120,90,7.9,98,70,low risk
497
+ 42,120,80,7.9,98,70,low risk
498
+ 23,90,60,7.9,98,76,low risk
499
+ 19,120,80,7,98,70,low risk
500
+ 15,76,49,7.9,98,77,low risk
501
+ 16,120,75,7.9,98,7,low risk
502
+ 48,120,80,11,98,88,mid risk
503
+ 15,120,80,7.9,98,70,low risk
504
+ 63,140,90,15,98,90,high risk
505
+ 25,140,100,7.9,98,80,high risk
506
+ 30,120,80,7.9,101,76,high risk
507
+ 17,70,50,7.9,98,70,low risk
508
+ 55,140,100,18,98,90,high risk
509
+ 18,120,80,7.9,102,76,mid risk
510
+ 32,140,100,7.9,98,78,high risk
511
+ 17,90,60,7.5,101,76,low risk
512
+ 17,90,63,7.5,101,70,low risk
513
+ 25,120,90,7.5,101,80,low risk
514
+ 17,120,80,7.5,102,76,low risk
515
+ 19,90,65,7.5,101,70,low risk
516
+ 15,80,60,7.5,98,80,low risk
517
+ 60,90,65,7.5,98,77,low risk
518
+ 18,85,60,7.5,101,86,mid risk
519
+ 50,120,80,7.5,98,70,low risk
520
+ 19,90,70,7.5,98,80,low risk
521
+ 30,140,100,15,98,70,high risk
522
+ 31,120,60,6.1,98,76,low risk
523
+ 23,120,90,7.5,98,70,low risk
524
+ 29,130,70,7.5,98,78,mid risk
525
+ 17,85,60,7.5,102,86,low risk
526
+ 32,120,90,7.5,98,70,low risk
527
+ 42,120,80,7.5,98,70,low risk
528
+ 42,90,60,7.5,98,76,low risk
529
+ 19,120,80,7,98,70,low risk
530
+ 15,78,49,7.5,98,77,low risk
531
+ 23,120,75,8,98,70,mid risk
532
+ 48,120,80,11,98,88,high risk
533
+ 15,120,80,7.5,98,70,mid risk
534
+ 49,140,90,15,98,90,high risk
535
+ 25,140,100,7.5,98,80,high risk
536
+ 30,120,80,7.5,101,76,mid risk
537
+ 16,70,50,7.5,100,70,low risk
538
+ 16,100,70,7.5,98,80,low risk
539
+ 19,120,75,7.5,98,66,low risk
540
+ 40,160,100,19,98,77,high risk
541
+ 32,140,90,18,98,88,high risk
542
+ 22,100,65,7.5,98,70,low risk
543
+ 49,120,90,7.5,98,77,low risk
544
+ 28,90,60,7.5,98,82,low risk
545
+ 12,90,60,7.5,102,66,low risk
546
+ 20,100,90,7.5,98,88,low risk
547
+ 23,100,85,7.5,98,66,low risk
548
+ 22,120,90,7.5,98,82,low risk
549
+ 21,120,80,7.5,98,77,low risk
550
+ 35,140,100,7.5,98,66,high risk
551
+ 54,140,100,15,98,66,high risk
552
+ 40,120,95,11,98,80,mid risk
553
+ 21,75,50,7.5,98,60,low risk
554
+ 12,90,60,7.5,102,60,low risk
555
+ 60,120,85,15,98,60,mid risk
556
+ 55,140,95,19,98,77,high risk
557
+ 50,130,100,16,98,75,mid risk
558
+ 60,120,80,7.5,98,75,low risk
559
+ 55,100,65,7.5,98,66,low risk
560
+ 45,120,95,7.5,98,66,low risk
561
+ 35,100,70,7.5,98,66,low risk
562
+ 22,120,85,7.5,98,88,low risk
563
+ 13,90,65,7.5,101,80,low risk
564
+ 23,120,90,7.5,98,60,low risk
565
+ 17,90,65,7.5,103,67,low risk
566
+ 28,115,60,7.5,101,86,mid risk
567
+ 59,120,80,7.5,98,70,low risk
568
+ 29,120,70,9,98,80,high risk
569
+ 23,120,80,7.5,98,70,low risk
570
+ 31,120,60,6.1,98,76,mid risk
571
+ 23,120,80,7.5,98,70,mid risk
572
+ 29,130,70,7.5,98,78,mid risk
573
+ 17,85,60,7.5,102,86,low risk
574
+ 32,120,90,7.5,98,70,low risk
575
+ 42,120,80,7.5,98,70,low risk
576
+ 23,90,60,7.5,98,76,low risk
577
+ 19,120,80,7,98,70,low risk
578
+ 15,78,49,7.5,98,77,low risk
579
+ 20,120,75,7.5,98,70,low risk
580
+ 48,120,80,11,98,88,high risk
581
+ 15,120,80,7.5,98,70,low risk
582
+ 24,120,80,7.5,98,66,low risk
583
+ 16,100,70,7.5,98,80,low risk
584
+ 19,120,76,7.5,98,66,low risk
585
+ 40,160,100,19,98,77,high risk
586
+ 32,140,90,18,98,88,high risk
587
+ 22,100,65,7.5,98,70,mid risk
588
+ 49,120,90,7.5,98,77,mid risk
589
+ 28,90,60,7.5,98,82,mid risk
590
+ 12,90,60,7.5,102,66,mid risk
591
+ 20,100,90,7.5,98,88,mid risk
592
+ 23,100,85,7.5,98,66,mid risk
593
+ 22,120,90,7.5,98,82,mid risk
594
+ 21,120,80,7.5,98,77,mid risk
595
+ 35,140,100,7.5,98,66,high risk
596
+ 54,140,100,15,98,66,high risk
597
+ 40,120,95,11,98,80,high risk
598
+ 21,75,50,7.5,98,60,low risk
599
+ 22,90,60,7.5,102,60,high risk
600
+ 40,120,85,15,98,60,high risk
601
+ 55,140,95,19,98,77,high risk
602
+ 50,130,100,16,98,75,high risk
603
+ 60,120,80,7.5,98,75,mid risk
604
+ 40,120,85,15,98,60,high risk
605
+ 55,140,95,19,98,77,high risk
606
+ 50,130,100,16,98,75,mid risk
607
+ 41,120,80,7.5,98,75,low risk
608
+ 55,100,65,7.5,98,66,low risk
609
+ 45,120,95,7.5,98,66,low risk
610
+ 35,100,70,7.5,98,66,low risk
611
+ 22,120,85,7.5,98,88,low risk
612
+ 13,90,65,7.5,101,80,high risk
613
+ 23,120,90,7.5,98,60,low risk
614
+ 17,90,65,7.5,103,67,mid risk
615
+ 27,135,60,7.5,101,86,high risk
616
+ 50,120,80,15,98,70,high risk
617
+ 34,110,70,7,98,80,high risk
618
+ 32,120,80,7.5,98,70,low risk
619
+ 31,120,60,6.1,98,76,low risk
620
+ 23,120,90,7.5,98,70,low risk
621
+ 29,130,70,7.5,98,78,mid risk
622
+ 17,85,60,7.5,101,86,high risk
623
+ 32,120,90,7.5,98,70,low risk
624
+ 42,120,80,7.5,98,70,low risk
625
+ 23,90,60,7.5,98,76,low risk
626
+ 19,120,80,7,98,70,mid risk
627
+ 15,76,49,7.5,98,77,low risk
628
+ 20,120,76,7.5,98,70,low risk
629
+ 48,120,80,11,98,88,high risk
630
+ 15,120,80,7.5,98,70,low risk
631
+ 24,120,80,7.5,98,66,low risk
632
+ 22,100,65,12,98,80,high risk
633
+ 50,140,95,17,98,60,high risk
634
+ 35,100,70,11,98,60,high risk
635
+ 19,120,85,9,98,60,mid risk
636
+ 30,90,65,8,98,77,mid risk
637
+ 28,85,60,9,101,86,mid risk
638
+ 50,130,80,15,98,86,high risk
639
+ 35,140,90,13,98,70,high risk
640
+ 29,90,70,11,100,80,high risk
641
+ 19,120,60,7,98.4,70,low risk
642
+ 46,140,100,12,99,90,high risk
643
+ 28,95,60,10,101,86,high risk
644
+ 50,120,80,7,98,70,mid risk
645
+ 39,110,70,7.9,98,80,mid risk
646
+ 25,140,100,15,98.6,70,high risk
647
+ 31,120,60,6.1,98,76,low risk
648
+ 23,120,85,8,98,70,low risk
649
+ 29,130,70,8,98,78,mid risk
650
+ 17,90,60,9,102,86,mid risk
651
+ 32,120,90,7,100,70,mid risk
652
+ 42,120,90,9,98,70,mid risk
653
+ 23,90,60,6.7,98,76,low risk
654
+ 19,120,80,7,98,70,low risk
655
+ 15,76,68,7,98,77,low risk
656
+ 34,120,75,8,98,70,low risk
657
+ 48,120,80,11,98,88,high risk
658
+ 15,120,80,6.6,99,70,low risk
659
+ 27,140,90,15,98,90,high risk
660
+ 25,140,100,12,99,80,high risk
661
+ 36,120,90,7,98,82,mid risk
662
+ 30,120,80,9,101,76,mid risk
663
+ 15,70,50,6,98,70,mid risk
664
+ 40,120,95,7,98,70,high risk
665
+ 15,90,60,6,98,80,low risk
666
+ 21,90,50,6.9,98,60,low risk
667
+ 15,90,49,6,98,77,low risk
668
+ 21,90,50,6.5,98,60,low risk
669
+ 15,90,49,6,98,77,low risk
670
+ 15,90,49,6.7,99,77,low risk
671
+ 15,90,49,6,99,77,low risk
672
+ 10,100,50,6,99,70,mid risk
673
+ 15,100,49,6.8,99,77,low risk
674
+ 15,100,49,6,99,77,low risk
675
+ 12,100,50,6.4,98,70,mid risk
676
+ 15,100,60,6,98,80,low risk
677
+ 35,140,90,13,98,70,high risk
678
+ 29,90,70,8,100,80,high risk
679
+ 30,140,85,7,98,70,high risk
680
+ 23,140,80,7.01,98,70,high risk
681
+ 35,85,60,11,102,86,high risk
682
+ 42,130,80,18,98,70,high risk
683
+ 50,140,90,15,98,90,high risk
684
+ 25,140,100,7.01,98,80,high risk
685
+ 40,140,100,18,98,90,high risk
686
+ 32,140,100,6.9,98,78,high risk
687
+ 14,90,65,7,101,70,high risk
688
+ 37,120,90,11,98,88,high risk
689
+ 17,110,75,12,101,76,high risk
690
+ 40,120,90,12,98,80,high risk
691
+ 40,160,100,19,98,77,high risk
692
+ 20,120,76,7.5,98,70,low risk
693
+ 15,120,80,7.5,98,70,low risk
694
+ 24,120,80,7.5,98,66,low risk
695
+ 19,120,60,7,98.4,70,low risk
696
+ 31,120,60,6.1,98,76,low risk
697
+ 23,120,85,8,98,70,low risk
698
+ 23,90,60,6.7,98,76,low risk
699
+ 19,120,80,7,98,70,low risk
700
+ 15,76,68,7,98,77,low risk
701
+ 34,120,75,8,98,70,low risk
702
+ 15,120,80,6.6,99,70,low risk
703
+ 15,90,60,6,98,80,low risk
704
+ 21,90,50,6.9,98,60,low risk
705
+ 15,100,49,7.6,98,77,low risk
706
+ 12,100,50,6,98,70,mid risk
707
+ 21,100,50,6.8,98,60,low risk
708
+ 23,130,70,7.01,98,78,mid risk
709
+ 32,120,90,6.9,98,70,mid risk
710
+ 19,120,80,7,98,70,mid risk
711
+ 20,120,75,7.01,100,70,mid risk
712
+ 48,120,80,11,98,88,mid risk
713
+ 30,120,80,6.9,101,76,mid risk
714
+ 18,120,80,6.9,102,76,mid risk
715
+ 17,90,60,6.9,101,76,mid risk
716
+ 17,90,63,6.9,101,70,mid risk
717
+ 25,120,90,6.7,101,80,mid risk
718
+ 17,120,80,6.7,102,76,mid risk
719
+ 13,90,65,7.9,101,80,mid risk
720
+ 31,120,60,6.1,98,76,mid risk
721
+ 29,130,70,6.1,98,78,mid risk
722
+ 19,120,80,7,98,70,mid risk
723
+ 28,85,60,9,101,86,mid risk
724
+ 50,140,80,6.7,98,70,mid risk
725
+ 29,90,70,6.7,98,80,mid risk
726
+ 31,120,60,6.1,98,76,mid risk
727
+ 29,130,70,6.7,98,78,mid risk
728
+ 17,85,60,9,102,86,mid risk
729
+ 19,120,80,7,98,70,mid risk
730
+ 20,110,60,7,100,70,mid risk
731
+ 19,120,80,7,98,70,mid risk
732
+ 20,120,75,7.01,100,70,mid risk
733
+ 48,120,80,11,98,88,mid risk
734
+ 30,120,80,6.9,101,76,mid risk
735
+ 18,120,80,6.9,102,76,mid risk
736
+ 17,90,60,6.9,101,76,mid risk
737
+ 17,90,63,6.9,101,70,mid risk
738
+ 25,120,90,6.7,101,80,mid risk
739
+ 17,120,80,6.7,102,76,mid risk
740
+ 13,90,65,7.9,101,80,mid risk
741
+ 31,120,60,6.1,98,76,mid risk
742
+ 29,130,70,6.1,98,78,mid risk
743
+ 19,120,80,7,98,70,mid risk
744
+ 28,85,60,9,101,86,mid risk
745
+ 50,140,80,6.7,98,70,mid risk
746
+ 29,90,70,6.7,98,80,mid risk
747
+ 31,120,60,6.1,98,76,mid risk
748
+ 29,130,70,6.7,98,78,mid risk
749
+ 17,85,60,9,102,86,mid risk
750
+ 19,120,80,7,98,70,mid risk
751
+ 20,110,60,7,100,70,mid risk
752
+ 32,120,65,6,101,76,mid risk
753
+ 26,85,60,6,101,86,mid risk
754
+ 29,130,70,7.7,98,78,mid risk
755
+ 19,120,80,7,98,70,mid risk
756
+ 54,130,70,12,98,67,mid risk
757
+ 44,120,90,16,98,80,mid risk
758
+ 23,130,70,6.9,98,70,mid risk
759
+ 22,85,60,6.9,98,76,mid risk
760
+ 55,120,90,12,98,70,mid risk
761
+ 35,120,80,6.9,98,78,mid risk
762
+ 21,90,60,6.9,98,86,mid risk
763
+ 16,90,65,6.9,98,76,mid risk
764
+ 33,115,65,7,98,70,mid risk
765
+ 12,95,60,6.9,98,65,mid risk
766
+ 28,120,90,6.9,98,70,mid risk
767
+ 21,90,65,6.9,98,76,mid risk
768
+ 18,90,60,6.9,98,70,mid risk
769
+ 21,120,80,6.9,98,76,mid risk
770
+ 16,100,70,6.9,98,80,mid risk
771
+ 19,120,75,6.9,98,66,mid risk
772
+ 23,100,85,6.9,98,66,mid risk
773
+ 22,120,90,7.8,98,82,mid risk
774
+ 60,120,85,15,98,60,mid risk
775
+ 13,90,65,7.8,101,80,mid risk
776
+ 23,120,90,7.8,98,60,mid risk
777
+ 28,115,60,7.8,101,86,mid risk
778
+ 50,120,80,7.8,98,70,mid risk
779
+ 29,130,70,7.8,98,78,mid risk
780
+ 19,120,80,7,98,70,mid risk
781
+ 19,120,85,7.8,98,60,mid risk
782
+ 60,90,65,6.8,98,77,mid risk
783
+ 55,120,90,6.8,98,66,mid risk
784
+ 25,120,80,6.8,98,66,mid risk
785
+ 12,120,95,6.8,98,60,mid risk
786
+ 35,100,70,6.8,98,60,mid risk
787
+ 19,120,90,6.8,98,60,mid risk
788
+ 60,90,65,6.8,98,77,mid risk
789
+ 50,130,80,16,102,76,mid risk
790
+ 27,120,90,6.8,102,68,mid risk
791
+ 55,100,70,6.8,101,80,mid risk
792
+ 12,120,90,6.8,98,80,mid risk
793
+ 60,120,80,6.8,98,77,mid risk
794
+ 25,120,100,6.8,98,60,mid risk
795
+ 22,90,60,6.8,98,77,mid risk
796
+ 30,120,80,6.8,98,70,mid risk
797
+ 35,120,60,6.1,98,76,mid risk
798
+ 23,130,70,6.8,98,78,mid risk
799
+ 43,130,80,18,98,70,mid risk
800
+ 19,120,80,7,98,70,mid risk
801
+ 30,120,75,6.8,98,70,mid risk
802
+ 32,120,80,6.8,98,70,mid risk
803
+ 23,130,70,6.8,98,78,mid risk
804
+ 42,130,80,18,98,70,mid risk
805
+ 19,120,80,7,98,70,mid risk
806
+ 31,110,90,6.8,100,70,mid risk
807
+ 19,120,80,7,98,70,mid risk
808
+ 25,120,80,7.9,98,66,mid risk
809
+ 31,120,60,6.1,98,76,mid risk
810
+ 23,120,90,7.9,98,70,mid risk
811
+ 29,130,70,7.9,98,78,mid risk
812
+ 48,120,80,11,98,88,mid risk
813
+ 18,120,80,7.9,102,76,mid risk
814
+ 18,85,60,7.5,101,86,mid risk
815
+ 29,130,70,7.5,98,78,mid risk
816
+ 23,120,75,8,98,70,mid risk
817
+ 15,120,80,7.5,98,70,mid risk
818
+ 30,120,80,7.5,101,76,mid risk
819
+ 40,120,95,11,98,80,mid risk
820
+ 60,120,85,15,98,60,mid risk
821
+ 50,130,100,16,98,75,mid risk
822
+ 28,115,60,7.5,101,86,mid risk
823
+ 31,120,60,6.1,98,76,mid risk
824
+ 23,120,80,7.5,98,70,mid risk
825
+ 29,130,70,7.5,98,78,mid risk
826
+ 22,100,65,7.5,98,70,mid risk
827
+ 49,120,90,7.5,98,77,mid risk
828
+ 28,90,60,7.5,98,82,mid risk
829
+ 12,90,60,7.5,102,66,mid risk
830
+ 20,100,90,7.5,98,88,mid risk
831
+ 23,100,85,7.5,98,66,mid risk
832
+ 22,120,90,7.5,98,82,mid risk
833
+ 21,120,80,7.5,98,77,mid risk
834
+ 60,120,80,7.5,98,75,mid risk
835
+ 50,130,100,16,98,75,mid risk
836
+ 17,90,65,7.5,103,67,mid risk
837
+ 29,130,70,7.5,98,78,mid risk
838
+ 19,120,80,7,98,70,mid risk
839
+ 19,120,85,9,98,60,mid risk
840
+ 30,90,65,8,98,77,mid risk
841
+ 28,85,60,9,101,86,mid risk
842
+ 50,120,80,7,98,70,mid risk
843
+ 39,110,70,7.9,98,80,mid risk
844
+ 29,130,70,8,98,78,mid risk
845
+ 17,90,60,9,102,86,mid risk
846
+ 32,120,90,7,100,70,mid risk
847
+ 42,120,90,9,98,70,mid risk
848
+ 36,120,90,7,98,82,mid risk
849
+ 30,120,80,9,101,76,mid risk
850
+ 15,70,50,6,98,70,mid risk
851
+ 10,100,50,6,99,70,mid risk
852
+ 12,100,50,6.4,98,70,mid risk
853
+ 12,100,50,6,98,70,mid risk
854
+ 23,130,70,7.01,98,78,mid risk
855
+ 32,120,90,6.9,98,70,mid risk
856
+ 19,120,80,7,98,70,mid risk
857
+ 20,120,75,7.01,100,70,mid risk
858
+ 48,120,80,11,98,88,mid risk
859
+ 30,120,80,6.9,101,76,mid risk
860
+ 18,120,80,6.9,102,76,mid risk
861
+ 17,90,60,6.9,101,76,mid risk
862
+ 17,90,63,6.9,101,70,mid risk
863
+ 25,120,90,6.7,101,80,mid risk
864
+ 17,120,80,6.7,102,76,mid risk
865
+ 13,90,65,7.9,101,80,mid risk
866
+ 31,120,60,6.1,98,76,mid risk
867
+ 29,130,70,6.1,98,78,mid risk
868
+ 19,120,80,7,98,70,mid risk
869
+ 28,85,60,9,101,86,mid risk
870
+ 50,140,80,6.7,98,70,mid risk
871
+ 29,90,70,6.7,98,80,mid risk
872
+ 31,120,60,6.1,98,76,mid risk
873
+ 29,130,70,6.7,98,78,mid risk
874
+ 17,85,60,9,102,86,mid risk
875
+ 19,120,80,7,98,70,mid risk
876
+ 20,110,60,7,100,70,mid risk
877
+ 32,120,65,6,101,76,mid risk
878
+ 27,120,70,6.8,98,77,low risk
879
+ 27,120,70,6.8,98,77,low risk
880
+ 32,120,90,6.8,98,70,low risk
881
+ 23,99,60,6.8,98,76,low risk
882
+ 15,76,49,6.8,98,77,low risk
883
+ 15,120,80,6.8,98,70,low risk
884
+ 29,100,70,6.8,98,80,low risk
885
+ 35,120,60,6.1,98,76,low risk
886
+ 32,120,90,6.8,98,70,low risk
887
+ 23,90,60,6.8,98,76,low risk
888
+ 15,76,49,6.8,98,77,low risk
889
+ 20,120,75,6.8,98,70,low risk
890
+ 48,120,80,11,98,88,low risk
891
+ 15,120,80,6.8,98,70,low risk
892
+ 30,120,80,6.8,101,76,low risk
893
+ 18,120,80,6.8,102,76,low risk
894
+ 17,90,60,7.9,101,76,low risk
895
+ 15,76,49,7.9,98,77,low risk
896
+ 19,120,75,7.9,98,70,low risk
897
+ 48,120,80,11,98,88,low risk
898
+ 15,120,80,7.9,98,70,low risk
899
+ 22,100,65,7.9,98,80,low risk
900
+ 35,100,70,7.9,98,60,low risk
901
+ 19,120,85,7.9,98,60,low risk
902
+ 60,90,65,7.9,98,77,low risk
903
+ 50,120,80,7.9,98,70,low risk
904
+ 17,85,60,7.9,102,86,low risk
905
+ 32,120,90,7.9,98,70,low risk
906
+ 42,120,80,7.9,98,70,low risk
907
+ 23,90,60,7.9,98,76,low risk
908
+ 19,120,80,7,98,70,low risk
909
+ 15,76,49,7.9,98,77,low risk
910
+ 16,120,75,7.9,98,7,low risk
911
+ 15,120,80,7.9,98,70,low risk
912
+ 17,70,50,7.9,98,70,low risk
913
+ 17,90,60,7.5,101,76,low risk
914
+ 17,90,63,7.5,101,70,low risk
915
+ 25,120,90,7.5,101,80,low risk
916
+ 17,120,80,7.5,102,76,low risk
917
+ 19,90,65,7.5,101,70,low risk
918
+ 15,80,60,7.5,98,80,low risk
919
+ 60,90,65,7.5,98,77,low risk
920
+ 50,120,80,7.5,98,70,low risk
921
+ 19,90,70,7.5,98,80,low risk
922
+ 31,120,60,6.1,98,76,low risk
923
+ 23,120,90,7.5,98,70,low risk
924
+ 17,85,60,7.5,102,86,low risk
925
+ 32,120,90,7.5,98,70,low risk
926
+ 42,120,80,7.5,98,70,low risk
927
+ 42,90,60,7.5,98,76,low risk
928
+ 19,120,80,7,98,70,low risk
929
+ 15,78,49,7.5,98,77,low risk
930
+ 16,70,50,7.5,100,70,low risk
931
+ 16,100,70,7.5,98,80,low risk
932
+ 19,120,75,7.5,98,66,low risk
933
+ 22,100,65,7.5,98,70,low risk
934
+ 49,120,90,7.5,98,77,low risk
935
+ 28,90,60,7.5,98,82,low risk
936
+ 12,90,60,7.5,102,66,low risk
937
+ 20,100,90,7.5,98,88,low risk
938
+ 23,100,85,7.5,98,66,low risk
939
+ 22,120,90,7.5,98,82,low risk
940
+ 21,120,80,7.5,98,77,low risk
941
+ 21,75,50,7.5,98,60,low risk
942
+ 12,90,60,7.5,102,60,low risk
943
+ 60,120,80,7.5,98,75,low risk
944
+ 55,100,65,7.5,98,66,low risk
945
+ 45,120,95,7.5,98,66,low risk
946
+ 35,100,70,7.5,98,66,low risk
947
+ 22,120,85,7.5,98,88,low risk
948
+ 13,90,65,7.5,101,80,low risk
949
+ 23,120,90,7.5,98,60,low risk
950
+ 17,90,65,7.5,103,67,low risk
951
+ 59,120,80,7.5,98,70,low risk
952
+ 23,120,80,7.5,98,70,low risk
953
+ 17,85,60,7.5,102,86,low risk
954
+ 32,120,90,7.5,98,70,low risk
955
+ 42,120,80,7.5,98,70,low risk
956
+ 25,140,100,7.01,98,80,high risk
957
+ 40,140,100,18,98,90,high risk
958
+ 32,140,100,6.9,98,78,high risk
959
+ 14,90,65,7,101,70,high risk
960
+ 37,120,90,11,98,88,high risk
961
+ 17,110,75,12,101,76,high risk
962
+ 40,120,90,12,98,80,high risk
963
+ 40,160,100,19,98,77,high risk
964
+ 32,140,90,18,98,88,high risk
965
+ 12,90,60,7.9,102,66,high risk
966
+ 35,140,100,8,98,66,high risk
967
+ 54,140,100,15,98,66,high risk
968
+ 40,120,95,11,98,80,high risk
969
+ 60,120,85,15,98,60,high risk
970
+ 55,140,95,19,98,77,high risk
971
+ 50,130,100,16,98,75,high risk
972
+ 17,90,65,6.1,103,67,high risk
973
+ 28,83,60,8,101,86,high risk
974
+ 50,120,80,15,98,70,high risk
975
+ 17,85,60,9,102,86,high risk
976
+ 33,120,75,10,98,70,high risk
977
+ 48,120,80,11,98,88,high risk
978
+ 50,140,95,17,98,60,high risk
979
+ 30,140,100,15,98,70,high risk
980
+ 29,120,75,7.2,100,70,high risk
981
+ 48,120,80,11,98,88,high risk
982
+ 50,140,90,15,98,77,high risk
983
+ 25,140,100,7.2,98,80,high risk
984
+ 55,140,80,7.2,101,76,high risk
985
+ 40,140,100,18,98,77,high risk
986
+ 28,120,80,9,102,76,high risk
987
+ 32,140,100,8,98,70,high risk
988
+ 17,90,60,11,101,78,high risk
989
+ 17,90,63,8,101,70,high risk
990
+ 25,120,90,12,101,80,high risk
991
+ 17,120,80,7,102,76,high risk
992
+ 19,90,65,11,101,70,high risk
993
+ 37,120,90,11,98,88,high risk
994
+ 17,110,75,13,101,76,high risk
995
+ 25,120,90,15,98,80,high risk
996
+ 40,160,100,19,98,77,high risk
997
+ 32,140,90,18,98,88,high risk
998
+ 12,90,60,8,102,66,high risk
999
+ 35,140,100,9,98,66,high risk
1000
+ 54,140,100,15,98,66,high risk
1001
+ 40,120,95,11,98,80,high risk
1002
+ 12,90,60,11,102,60,high risk
1003
+ 60,120,85,15,98,60,high risk
1004
+ 55,140,95,19,98,77,high risk
1005
+ 50,130,100,16,98,76,high risk
1006
+ 13,90,65,9,101,80,high risk
1007
+ 17,90,65,7.7,103,67,high risk
1008
+ 17,85,60,6.3,102,86,high risk
1009
+ 40,120,75,7.7,98,70,high risk
1010
+ 48,120,80,11,98,88,high risk
1011
+ 22,120,60,15,98,80,high risk
1012
+ 55,120,90,18,98,60,high risk
1013
+ 35,85,60,19,98,86,high risk
1014
+ 43,120,90,18,98,70,high risk
1015
+ 32,120,65,6,101,76,mid risk
deployment/ngos_guide.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying MamaGuard at your clinic or NGO
2
+
3
+ ## What you need
4
+
5
+ - Any laptop or desktop computer (even a 2015 model works)
6
+ - Python 3.10 or higher (free download at python.org)
7
+ - The clinic's local WiFi network (so multiple devices can use the dashboard)
8
+
9
+ ## Setup for a new clinic (one-time, ~30 minutes)
10
+
11
+ 1. Copy the entire mamaGuard/ folder onto the clinic computer
12
+ 2. Open a terminal (Command Prompt on Windows)
13
+ 3. cd into the mamaGuard folder
14
+ 4. Run: pip install -r requirements.txt
15
+ 5. Run: python -m src.train
16
+ 6. Run: uvicorn api.main:app --host 0.0.0.0 --port 8000
17
+ (--host 0.0.0.0 means any device on the network can reach it)
18
+
19
+ ## Using the dashboard (for health workers)
20
+
21
+ 1. On any phone or tablet connected to the clinic WiFi, open a browser
22
+ 2. Go to: http://[clinic-computer-IP]:8000/dashboard
23
+ (The IT person can find the IP in the computer's network settings)
24
+ 3. Enter the patient's ID and their prenatal visit readings
25
+ 4. Press "Assess maternal risk"
26
+ 5. Follow the action instruction shown
27
+
28
+ ## Understanding the three alert levels
29
+
30
+ 🟢 GREEN — Continue standard care. Schedule next visit as normal.
31
+ 🟡 AMBER — Elevated risk. Add an extra checkup within 72 hours.
32
+ 🔴 RED — High risk. Refer to hospital. If a transfer order is shown, call
33
+ the referral line immediately.
34
+
35
+ ## What to do about LOW CONFIDENCE alerts
36
+
37
+ If you see "Data quality: low" on any result, record the missing readings
38
+ at the next visit. The model's accuracy improves significantly when all
39
+ six fields (Age, Systolic BP, Diastolic BP, Blood Sugar, Temperature,
40
+ Heart Rate) are filled in.
41
+
42
+ ## Weekly reporting
43
+
44
+ Go to http://[clinic-computer-IP]:8000/stats to see a summary of all
45
+ patients assessed this week, broken down by risk level. This can be
46
+ emailed to district health authorities.
47
+
48
+ ## If the server is not running
49
+
50
+ The health worker will see "Could not reach the server."
51
+ Ask the IT contact to re-run: uvicorn api.main:app --host 0.0.0.0 --port 8000
models/confusion_matrix.png ADDED
models/evaluation_report.txt ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ╔══════════════════════════════════════════════════════════════════════════╗
3
+ ║ MAMAGUARD — MODEL EVALUATION REPORT ║
4
+ ║ Generated automatically by src/evaluate.py ║
5
+ ╚══════════════════════════════════════════════════════════════════════════╝
6
+
7
+ MODEL
8
+ Architecture : MamaGuard-Mamba3 (Trapezoidal SSM + MIMO + Complex state)
9
+ Parameters : ~287,815
10
+ Input : Sequence of prenatal visits (up to 5) × 6 vital signs
11
+ Output : 3-class risk prediction (Low / Medium / High)
12
+ Dataset : UCI Maternal Health Risk (1,014 rows)
13
+ Training set : 1525 sequences
14
+ Validation set: 191 sequences
15
+
16
+ ──────────────────────────────────────────────────────────────────────────
17
+ PERFORMANCE METRICS (on held-out validation set)
18
+ ──────────────────────────────────────────────────────────────────────────
19
+
20
+ Overall accuracy : 0.880 (88.0%)
21
+
22
+ ROC-AUC per class:
23
+ Low risk : 0.977
24
+ Medium risk : 0.925
25
+ High risk : 0.995
26
+
27
+ ** HIGH RISK RECALL (most critical for patient safety):
28
+ 1.000 — of all truly high-risk patients, 100.0% were correctly flagged
29
+
30
+ ** HIGH RISK PRECISION (alarm fatigue indicator):
31
+ 0.895 — of all patients flagged high-risk, 89.5% truly were
32
+
33
+ Detailed per-class report:
34
+ precision recall f1-score support
35
+
36
+ Low risk 0.917 0.857 0.886 77
37
+ Medium risk 0.823 0.810 0.816 63
38
+ High risk 0.895 1.000 0.944 51
39
+
40
+ accuracy 0.880 191
41
+ macro avg 0.878 0.889 0.882 191
42
+ weighted avg 0.880 0.880 0.878 191
43
+
44
+
45
+ ──────────────────────────────────────────────────────────────────────────
46
+ CONFUSION MATRIX (raw counts)
47
+ ──────────────────────────────────────────────────────────────────────────
48
+ Rows = true label, Columns = predicted label
49
+
50
+ Pred: Low Pred: Mid Pred: High
51
+ True: Low 66 11 0
52
+ True: Mid 6 51 6
53
+ True: High 0 0 51
54
+
55
+ Most dangerous mistakes (False Negatives for High Risk):
56
+ High-risk patients predicted as Low risk : 0
57
+ High-risk patients predicted as Mid risk : 0
58
+
59
+ ──────────────────────────────────────────────────────────────────────────
60
+ HYBRID SYSTEM NOTE
61
+ ──────────────────────────────────────────────────────────────────────────
62
+ MamaGuard uses a HYBRID architecture:
63
+ 1. Mamba3 model: handles subtle temporal patterns (learned from data)
64
+ 2. WHO clinical rules: hard overrides for obvious danger signs
65
+ - Rule 1: SystolicBP >= 160 -> RED
66
+ - Rule 2: SystolicBP >= 140 -> AMBER minimum
67
+ - Rule 3: Blood sugar > 11.1 -> AMBER minimum
68
+ - Rule 4: BP rise >= 20 mmHg -> AMBER minimum
69
+ - Rule 5: 3+ vitals escalating simultaneously -> RED
70
+
71
+ The metrics above reflect the NEURAL MODEL ONLY (without clinical rules).
72
+ In deployment, the clinical rules provide an additional safety floor,
73
+ meaning real-world recall for high-risk cases is higher than shown above.
74
+
75
+ ──────────────────────────────────────────────────────────────────────────
76
+ LIMITATIONS
77
+ ──────────────────────────────────────────────────────────────────────────
78
+ 1. SMALL DATASET: Trained on 1,014 rows from a single UCI dataset.
79
+ Real-world clinical models typically require 10,000–100,000+ samples.
80
+
81
+ 2. SYNTHETIC SEQUENCES: The UCI dataset has no patient IDs or timestamps.
82
+ We created artificial 5-visit sequences by sorting rows by age.
83
+ These do not represent real patient trajectories.
84
+
85
+ 3. NOT CLINICALLY VALIDATED: This model has NOT been validated against
86
+ real patient outcomes in a clinical setting. It must NOT be used
87
+ for actual medical decisions without proper clinical validation trials.
88
+
89
+ 4. POPULATION BIAS: The UCI dataset was collected from a specific
90
+ population. Performance may differ on patients from different
91
+ regions, ethnicities, or healthcare contexts.
92
+
93
+ 5. RESEARCH PROTOTYPE: This is a proof-of-concept demonstrating the
94
+ application of Mamba3 SSMs to maternal health risk prediction.
95
+ The system design (alarm fatigue mitigation, resource-aware routing,
96
+ OCR auto-fill) represents the primary contribution of this work.
97
+
98
+ ──────────────────────────────────────────────────────────────────────────
99
+ CITATION
100
+ ──────────────────────────────────────────────────────────────────────────
101
+ If you use this work, please cite:
102
+ MamaGuard: Maternal Mortality Early Warning using Mamba3 Sequential
103
+ State-Space Models with Clinical Safety Rules.
104
+ [Your Name], 2025. GitHub: [your-repo-url]
105
+ Based on: Gu & Dao (2023) Mamba; UCI Maternal Health Risk dataset.
106
+
107
+ ══════════════════════════════════════════════════════════════════════════
models/mamaguard_mamba3.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ad79f8d1baea4ba994bba405e0e849cb5a19d0960ad4d2b19eb7a0cbec985873
3
+ size 1179269
models/roc_curves.png ADDED
models/scaler.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:90559a3874dfc35c38feea7d5b08faf2a42eb023ef6e2dc23e9c8e42e1a77cee
3
+ size 560
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.1.0
2
+ numpy>=1.24.0
3
+ pandas>=2.0.0
4
+ scikit-learn>=1.3.0
5
+ fastapi>=0.104.0
6
+ uvicorn>=0.24.0
7
+ pydantic>=2.0.0
8
+ shap>=0.44.0
9
+ einops>=0.7.0
10
+ matplotlib>=3.7.0
11
+ opencv-python-headless>=4.8.0
12
+ pytesseract>=0.3.10
13
+ Pillow>=10.0.0
14
+ python-multipart>=0.0.6
src/__init__.py ADDED
File without changes
src/data_pipeline.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — Data Pipeline
3
+ Loads UCI CSV, cleans, scales, builds sequences, and splits into train/val.
4
+ """
5
+
6
+ import pandas as pd
7
+ import numpy as np
8
+ import torch
9
+ from torch.utils.data import Dataset, DataLoader
10
+ from sklearn.preprocessing import StandardScaler
11
+ from sklearn.model_selection import train_test_split
12
+
13
+ # ─── Constants ────────────────────────────────────────────────────────────────
14
+
15
+ FEATURE_COLS = ['Age', 'SystolicBP', 'DiastolicBP', 'BS', 'BodyTemp', 'HeartRate']
16
+ LABEL_COL = 'RiskLevel'
17
+ LABEL_MAP = {'low risk': 0, 'mid risk': 1, 'high risk': 2}
18
+ SEQ_LEN = 5
19
+
20
+
21
+ # ─── Data quality scorer ─────────────────────────────────────────────────────
22
+
23
+ def compute_data_quality(row: pd.Series) -> float:
24
+ """Returns 0.0–1.0 indicating how complete and plausible the data is."""
25
+ score, total = 0.0, 0
26
+
27
+ for col in FEATURE_COLS:
28
+ total += 1
29
+ if pd.notna(row.get(col)):
30
+ score += 1.0
31
+
32
+ plausibility = {
33
+ 'Age': (10, 60), 'SystolicBP': (70, 200), 'DiastolicBP': (40, 130),
34
+ 'BS': (3.0, 20.0), 'BodyTemp': (35.0, 42.0), 'HeartRate': (40, 160),
35
+ }
36
+ for col, (lo, hi) in plausibility.items():
37
+ total += 1
38
+ val = row.get(col)
39
+ if pd.notna(val) and lo <= val <= hi:
40
+ score += 1.0
41
+
42
+ return round(score / total, 3)
43
+
44
+
45
+ # ─── Sequence builders ────────────────────────────────────────────────────────
46
+
47
+ def build_type_a_sequences(X: np.ndarray, y: np.ndarray,
48
+ quality: np.ndarray) -> tuple:
49
+ """Type A — Within-risk-group sliding window sequences."""
50
+ X_seq, y_seq, q_seq = [], [], []
51
+
52
+ for risk_label in [0, 1, 2]:
53
+ idx = np.where(y == risk_label)[0]
54
+ if len(idx) < SEQ_LEN:
55
+ continue
56
+
57
+ age_order = np.argsort(X[idx, 0])
58
+ idx_sorted = idx[age_order]
59
+
60
+ for i in range(len(idx_sorted) - SEQ_LEN + 1):
61
+ seq_idx = idx_sorted[i : i + SEQ_LEN]
62
+ X_seq.append(X[seq_idx].copy())
63
+ y_seq.append(risk_label)
64
+ q_seq.append(quality[seq_idx].mean())
65
+
66
+ return X_seq, y_seq, q_seq
67
+
68
+
69
+ def build_type_b_sequences(X: np.ndarray, y: np.ndarray,
70
+ rng: np.random.Generator) -> tuple:
71
+ """Type B — Synthetic escalation sequences for mid and high risk."""
72
+ X_seq, y_seq, q_seq = [], [], []
73
+
74
+ for risk_label in [1, 2]:
75
+ indices = np.where(y == risk_label)[0]
76
+ for idx in indices:
77
+ anchor = X[idx]
78
+ seq = []
79
+ for step in range(SEQ_LEN):
80
+ fraction = 0.30 + 0.70 * (step / (SEQ_LEN - 1))
81
+ noise = rng.normal(0, 0.05, anchor.shape)
82
+ seq.append(anchor * fraction + noise)
83
+ X_seq.append(np.array(seq, dtype=np.float32))
84
+ y_seq.append(risk_label)
85
+ q_seq.append(1.0)
86
+
87
+ return X_seq, y_seq, q_seq
88
+
89
+
90
+ def build_type_c_sequences(X: np.ndarray, y: np.ndarray,
91
+ rng: np.random.Generator,
92
+ n_per_transition: int = 80) -> tuple:
93
+ """Type C — Cross-risk deterioration sequences (low->mid, low->high, mid->high)."""
94
+ X_seq, y_seq, q_seq = [], [], []
95
+
96
+ low_idx = np.where(y == 0)[0]
97
+ mid_idx = np.where(y == 1)[0]
98
+ high_idx = np.where(y == 2)[0]
99
+
100
+ if len(low_idx) == 0 or len(mid_idx) == 0 or len(high_idx) == 0:
101
+ return X_seq, y_seq, q_seq
102
+
103
+ def blend(a: np.ndarray, b: np.ndarray, frac: float = 0.5) -> np.ndarray:
104
+ """Linear interpolation between two rows + small noise."""
105
+ return a * (1 - frac) + b * frac + rng.normal(0, 0.05, a.shape)
106
+
107
+ def pick(indices: np.ndarray) -> np.ndarray:
108
+ """Pick one random row from an index array."""
109
+ return X[rng.choice(indices)]
110
+
111
+ # Transition 1: LOW -> HIGH
112
+ for _ in range(n_per_transition):
113
+ seq = [
114
+ pick(low_idx), pick(low_idx),
115
+ blend(pick(low_idx), pick(high_idx)),
116
+ pick(high_idx), pick(high_idx),
117
+ ]
118
+ X_seq.append(np.array(seq, dtype=np.float32))
119
+ y_seq.append(2)
120
+ q_seq.append(1.0)
121
+
122
+ # Transition 2: LOW -> MID
123
+ for _ in range(n_per_transition):
124
+ seq = [
125
+ pick(low_idx), pick(low_idx), pick(low_idx),
126
+ pick(mid_idx), pick(mid_idx),
127
+ ]
128
+ X_seq.append(np.array(seq, dtype=np.float32))
129
+ y_seq.append(1)
130
+ q_seq.append(1.0)
131
+
132
+ # Transition 3: MID -> HIGH
133
+ for _ in range(n_per_transition):
134
+ seq = [
135
+ pick(mid_idx), pick(mid_idx),
136
+ blend(pick(mid_idx), pick(high_idx)),
137
+ pick(high_idx), pick(high_idx),
138
+ ]
139
+ X_seq.append(np.array(seq, dtype=np.float32))
140
+ y_seq.append(2)
141
+ q_seq.append(1.0)
142
+
143
+ return X_seq, y_seq, q_seq
144
+
145
+
146
+ # ─── Internal helper ─────────────────────────────────────────────────────────
147
+
148
+ def _build_all_sequences(X: np.ndarray, y: np.ndarray,
149
+ quality: np.ndarray,
150
+ rng: np.random.Generator,
151
+ include_augmentation: bool,
152
+ label: str = "") -> tuple:
153
+ """Builds all sequence types from a given set of scaled rows."""
154
+ X_all, y_all, q_all = [], [], []
155
+
156
+ a_X, a_y, a_q = build_type_a_sequences(X, y, quality)
157
+ X_all += a_X; y_all += a_y; q_all += a_q
158
+ print(f" {label} Type A (within-class): {len(a_X):5d} sequences")
159
+
160
+ if include_augmentation:
161
+ b_X, b_y, b_q = build_type_b_sequences(X, y, rng)
162
+ X_all += b_X; y_all += b_y; q_all += b_q
163
+ print(f" {label} Type B (escalation): {len(b_X):5d} sequences")
164
+
165
+ min_cls = min(sum(y == 0), sum(y == 1), sum(y == 2))
166
+ if min_cls >= SEQ_LEN:
167
+ c_X, c_y, c_q = build_type_c_sequences(X, y, rng)
168
+ X_all += c_X; y_all += c_y; q_all += c_q
169
+ print(f" {label} Type C (cross-risk): {len(c_X):5d} sequences")
170
+ else:
171
+ print(f" {label} Type C skipped (smallest class has {min_cls} rows)")
172
+
173
+ return X_all, y_all, q_all
174
+
175
+
176
+ # ─── Main pipeline ────────────────────────────────────────────────────────────
177
+
178
+ def load_and_preprocess(csv_path: str):
179
+ """
180
+ Returns pre-split, pre-scaled sequence arrays + the fitted scaler.
181
+
182
+ Returns:
183
+ X_train, y_train, q_train, X_val, y_val, q_val, scaler
184
+ """
185
+
186
+ # 1. Load
187
+ df = pd.read_csv(csv_path)
188
+ print(f"Loaded {len(df)} rows from {csv_path}")
189
+ df[LABEL_COL] = df[LABEL_COL].str.lower().str.strip()
190
+
191
+ # 2. Quality scores BEFORE imputation
192
+ quality_per_row = df.apply(compute_data_quality, axis=1).values
193
+
194
+ # 3. Impute missing values
195
+ for col in FEATURE_COLS:
196
+ df[col] = df[col].fillna(df[col].median())
197
+
198
+ X_raw = df[FEATURE_COLS].values.astype(np.float32)
199
+ y_raw = df[LABEL_COL].map(LABEL_MAP).values
200
+
201
+ # 4. Split raw rows first (prevents data leakage and sequence overlap)
202
+ (X_train_raw, X_val_raw,
203
+ y_train_raw, y_val_raw,
204
+ q_train_raw, q_val_raw) = train_test_split(
205
+ X_raw, y_raw, quality_per_row,
206
+ test_size=0.2, random_state=42, stratify=y_raw
207
+ )
208
+ print(f"Raw split -> train: {len(X_train_raw)} rows | val: {len(X_val_raw)} rows")
209
+ print(f"Train classes: low={sum(y_train_raw==0)} "
210
+ f"mid={sum(y_train_raw==1)} high={sum(y_train_raw==2)}")
211
+ print(f"Val classes: low={sum(y_val_raw==0)} "
212
+ f"mid={sum(y_val_raw==1)} high={sum(y_val_raw==2)}")
213
+
214
+ # 5. Fit scaler on train rows only
215
+ scaler = StandardScaler()
216
+ X_train_scaled = scaler.fit_transform(X_train_raw)
217
+ X_val_scaled = scaler.transform(X_val_raw)
218
+
219
+ # 6. Build sequences separately
220
+ rng = np.random.default_rng(42)
221
+
222
+ print("\nBuilding TRAINING sequences:")
223
+ train_seqs = _build_all_sequences(
224
+ X_train_scaled, y_train_raw, q_train_raw,
225
+ rng, include_augmentation=True, label="Train"
226
+ )
227
+
228
+ print("\nBuilding VALIDATION sequences:")
229
+ val_seqs = _build_all_sequences(
230
+ X_val_scaled, y_val_raw, q_val_raw,
231
+ rng, include_augmentation=False, label="Val"
232
+ )
233
+
234
+ X_train, y_train, q_train = [np.array(a) for a in train_seqs]
235
+ X_val, y_val, q_val = [np.array(a) for a in val_seqs]
236
+
237
+ print(f"\nFinal -> Train: {len(X_train)} seqs | Val: {len(X_val)} seqs")
238
+ print(f"Train dist: {dict(zip(*np.unique(y_train, return_counts=True)))}")
239
+ print(f"Val dist: {dict(zip(*np.unique(y_val, return_counts=True)))}")
240
+
241
+ return (X_train.astype(np.float32), y_train.astype(np.int64),
242
+ q_train.astype(np.float32),
243
+ X_val.astype(np.float32), y_val.astype(np.int64),
244
+ q_val.astype(np.float32),
245
+ scaler)
246
+
247
+
248
+ # ─── PyTorch Dataset ──────────────────────────────────────────────────────────
249
+
250
+ class MaternalDataset(Dataset):
251
+ def __init__(self, X, y, quality):
252
+ self.X = torch.tensor(np.array(X), dtype=torch.float32)
253
+ self.y = torch.tensor(np.array(y), dtype=torch.long)
254
+ self.q = torch.tensor(np.array(quality), dtype=torch.float32)
255
+
256
+ def __len__(self):
257
+ return len(self.X)
258
+
259
+ def __getitem__(self, idx):
260
+ return self.X[idx], self.y[idx], self.q[idx]
261
+
262
+
263
+ def get_dataloaders(csv_path: str, batch_size: int = 32):
264
+ """Returns train/val DataLoaders and the fitted scaler."""
265
+ (X_train, y_train, q_train,
266
+ X_val, y_val, q_val,
267
+ scaler) = load_and_preprocess(csv_path)
268
+
269
+ train_ds = MaternalDataset(X_train, y_train, q_train)
270
+ val_ds = MaternalDataset(X_val, y_val, q_val)
271
+
272
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
273
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)
274
+
275
+ print(f"\nDataLoaders ready:")
276
+ print(f" Train: {len(train_ds)} sequences | "
277
+ f"{len(train_loader)} batches of {batch_size}")
278
+ print(f" Val: {len(val_ds)} sequences | "
279
+ f"{len(val_loader)} batches")
280
+
281
+ return train_loader, val_loader, scaler
src/evaluate.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/evaluate.py
2
+ """
3
+ MamaGuard — Model Evaluation
4
+ Produces accuracy, per-class metrics, confusion matrix, ROC-AUC, and a text report.
5
+
6
+ Usage: python -m src.evaluate
7
+ """
8
+
9
+ import os
10
+ import pickle
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+ import matplotlib
15
+ matplotlib.use("Agg")
16
+ import matplotlib.pyplot as plt
17
+ import matplotlib.patches as mpatches
18
+
19
+ from sklearn.metrics import (
20
+ accuracy_score,
21
+ classification_report,
22
+ confusion_matrix,
23
+ roc_auc_score,
24
+ roc_curve,
25
+ )
26
+
27
+ from src.data_pipeline import load_and_preprocess
28
+ from src.model import MamaGuardMamba3
29
+
30
+ # ── Config ────────────────────────────────────────────────────────────────────
31
+ CSV_PATH = "data/maternal_health.csv"
32
+ MODEL_PATH = "models/mamaguard_mamba3.pt"
33
+ SCALER_PATH = "models/scaler.pkl"
34
+ REPORT_PATH = "models/evaluation_report.txt"
35
+ CM_PATH = "models/confusion_matrix.png"
36
+ ROC_PATH = "models/roc_curves.png"
37
+
38
+ CLASS_NAMES = ["Low risk", "Medium risk", "High risk"]
39
+ CLASS_COLORS = ["#2e7d32", "#e65100", "#c62828"]
40
+
41
+ # ── Load model ────────────────────────────────────────────────────────────────
42
+
43
+ def load_model(device: str):
44
+ """Load trained model weights into a fresh MamaGuardMamba3 instance."""
45
+ model = MamaGuardMamba3(
46
+ input_dim=6, d_model=64, n_layers=4, n_classes=3, d_state=32
47
+ )
48
+ model.load_state_dict(
49
+ torch.load(MODEL_PATH, map_location=device)
50
+ )
51
+ model.to(device)
52
+ model.eval()
53
+ return model
54
+
55
+
56
+ # ── Inference ─────────────────────────────────────────────────────────────────
57
+
58
+ def get_predictions(model, X: np.ndarray, device: str, batch_size: int = 64):
59
+ """
60
+ Run the model on all validation sequences in batches.
61
+ Returns: y_pred (N,), y_proba (N, 3)
62
+ """
63
+ model.eval()
64
+ all_probs = []
65
+
66
+ for i in range(0, len(X), batch_size):
67
+ batch = torch.tensor(X[i:i+batch_size], dtype=torch.float32).to(device)
68
+ with torch.no_grad():
69
+ logits = model(batch)
70
+ probs = F.softmax(logits, dim=-1).cpu().numpy()
71
+ all_probs.append(probs)
72
+
73
+ y_proba = np.vstack(all_probs)
74
+ y_pred = y_proba.argmax(axis=1)
75
+ return y_pred, y_proba
76
+
77
+
78
+ # ── Confusion matrix plot ─────────────────────────────────────────────────────
79
+
80
+ def plot_confusion_matrix(cm: np.ndarray, save_path: str):
81
+ """Plot raw and normalised confusion matrices side by side."""
82
+ fig, axes = plt.subplots(1, 2, figsize=(14, 5))
83
+ fig.suptitle("MamaGuard Confusion Matrix", fontsize=14, fontweight="bold")
84
+
85
+ for ax_idx, (data, title) in enumerate([
86
+ (cm, "Raw counts"),
87
+ (cm.astype(float) / cm.sum(axis=1, keepdims=True), "Normalised (row %)")
88
+ ]):
89
+ im = axes[ax_idx].imshow(data, cmap="RdYlGn", vmin=0,
90
+ vmax=(1 if ax_idx == 1 else None))
91
+ axes[ax_idx].set_xticks(range(3))
92
+ axes[ax_idx].set_yticks(range(3))
93
+ axes[ax_idx].set_xticklabels(CLASS_NAMES, rotation=15, ha="right")
94
+ axes[ax_idx].set_yticklabels(CLASS_NAMES)
95
+ axes[ax_idx].set_xlabel("Predicted label")
96
+ axes[ax_idx].set_ylabel("True label")
97
+ axes[ax_idx].set_title(title)
98
+
99
+ for i in range(3):
100
+ for j in range(3):
101
+ val = data[i, j]
102
+ text = f"{val:.2f}" if ax_idx == 1 else str(int(val))
103
+ color = "white" if (ax_idx == 1 and val < 0.4) or \
104
+ (ax_idx == 0 and val > cm.max() * 0.6) else "black"
105
+ axes[ax_idx].text(j, i, text, ha="center", va="center",
106
+ fontsize=11, color=color, fontweight="bold")
107
+
108
+ plt.colorbar(im, ax=axes[ax_idx], fraction=0.046, pad=0.04)
109
+
110
+ plt.tight_layout()
111
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
112
+ plt.close()
113
+ print(f" Confusion matrix saved -> {save_path}")
114
+
115
+
116
+ # ── ROC curves ────────────────────────────────────────────────────────────────
117
+
118
+ def plot_roc_curves(y_true: np.ndarray, y_proba: np.ndarray, save_path: str):
119
+ """Plot one-vs-rest ROC curves per class with AUC scores."""
120
+ from sklearn.preprocessing import label_binarize
121
+ y_bin = label_binarize(y_true, classes=[0, 1, 2])
122
+
123
+ fig, ax = plt.subplots(figsize=(8, 6))
124
+ ax.plot([0, 1], [0, 1], 'k--', linewidth=1, label="Random (AUC = 0.50)")
125
+
126
+ for i, (class_name, color) in enumerate(zip(CLASS_NAMES, CLASS_COLORS)):
127
+ fpr, tpr, _ = roc_curve(y_bin[:, i], y_proba[:, i])
128
+ auc = roc_auc_score(y_bin[:, i], y_proba[:, i])
129
+ ax.plot(fpr, tpr, color=color, linewidth=2,
130
+ label=f"{class_name} (AUC = {auc:.3f})")
131
+
132
+ ax.set_xlabel("False Positive Rate", fontsize=12)
133
+ ax.set_ylabel("True Positive Rate (Recall)", fontsize=12)
134
+ ax.set_title("ROC Curves — MamaGuard Mamba3", fontsize=13, fontweight="bold")
135
+ ax.legend(loc="lower right", fontsize=10)
136
+ ax.grid(alpha=0.3)
137
+
138
+ plt.tight_layout()
139
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
140
+ plt.close()
141
+ print(f" ROC curves saved -> {save_path}")
142
+
143
+
144
+ # ── Text report ───────────────────────────────────────────────────────────────
145
+
146
+ def build_text_report(
147
+ y_true, y_pred, y_proba,
148
+ class_report: str,
149
+ cm: np.ndarray,
150
+ n_train: int,
151
+ n_val: int
152
+ ) -> str:
153
+ """Build a complete text report for model card / README."""
154
+ overall_acc = accuracy_score(y_true, y_pred)
155
+
156
+ from sklearn.preprocessing import label_binarize
157
+ y_bin = label_binarize(y_true, classes=[0, 1, 2])
158
+ aucs = [roc_auc_score(y_bin[:, i], y_proba[:, i]) for i in range(3)]
159
+
160
+ hr_recall = cm[2, 2] / max(cm[2, :].sum(), 1)
161
+ hr_precision = cm[2, 2] / max(cm[:, 2].sum(), 1)
162
+
163
+ report = f"""
164
+ ╔══════════════════════════════════════════════════════════════════════════╗
165
+ ║ MAMAGUARD — MODEL EVALUATION REPORT ║
166
+ ║ Generated automatically by src/evaluate.py ║
167
+ ╚══════════════════════════════════════════════════════════════════════════╝
168
+
169
+ MODEL
170
+ Architecture : MamaGuard-Mamba3 (Trapezoidal SSM + MIMO + Complex state)
171
+ Parameters : ~287,815
172
+ Input : Sequence of prenatal visits (up to 5) × 6 vital signs
173
+ Output : 3-class risk prediction (Low / Medium / High)
174
+ Dataset : UCI Maternal Health Risk (1,014 rows)
175
+ Training set : {n_train} sequences
176
+ Validation set: {n_val} sequences
177
+
178
+ ──────────────────────────────────────────────────────────────────────────
179
+ PERFORMANCE METRICS (on held-out validation set)
180
+ ──────────────────────────────────────────────────────────────────────────
181
+
182
+ Overall accuracy : {overall_acc:.3f} ({overall_acc*100:.1f}%)
183
+
184
+ ROC-AUC per class:
185
+ Low risk : {aucs[0]:.3f}
186
+ Medium risk : {aucs[1]:.3f}
187
+ High risk : {aucs[2]:.3f}
188
+
189
+ ** HIGH RISK RECALL (most critical for patient safety):
190
+ {hr_recall:.3f} — of all truly high-risk patients, {hr_recall*100:.1f}% were correctly flagged
191
+
192
+ ** HIGH RISK PRECISION (alarm fatigue indicator):
193
+ {hr_precision:.3f} — of all patients flagged high-risk, {hr_precision*100:.1f}% truly were
194
+
195
+ Detailed per-class report:
196
+ {class_report}
197
+
198
+ ──────────────────────────────────────────────────────────────────────────
199
+ CONFUSION MATRIX (raw counts)
200
+ ──────────────────────────────────────────────────────────────────────────
201
+ Rows = true label, Columns = predicted label
202
+
203
+ Pred: Low Pred: Mid Pred: High
204
+ True: Low {cm[0,0]:5d} {cm[0,1]:5d} {cm[0,2]:5d}
205
+ True: Mid {cm[1,0]:5d} {cm[1,1]:5d} {cm[1,2]:5d}
206
+ True: High {cm[2,0]:5d} {cm[2,1]:5d} {cm[2,2]:5d}
207
+
208
+ Most dangerous mistakes (False Negatives for High Risk):
209
+ High-risk patients predicted as Low risk : {cm[2,0]}
210
+ High-risk patients predicted as Mid risk : {cm[2,1]}
211
+
212
+ ──────────────────────────────────────────────────────────────────────────
213
+ HYBRID SYSTEM NOTE
214
+ ──────────────────────────────────────────────────────────────────────────
215
+ MamaGuard uses a HYBRID architecture:
216
+ 1. Mamba3 model: handles subtle temporal patterns (learned from data)
217
+ 2. WHO clinical rules: hard overrides for obvious danger signs
218
+ - Rule 1: SystolicBP >= 160 -> RED
219
+ - Rule 2: SystolicBP >= 140 -> AMBER minimum
220
+ - Rule 3: Blood sugar > 11.1 -> AMBER minimum
221
+ - Rule 4: BP rise >= 20 mmHg -> AMBER minimum
222
+ - Rule 5: 3+ vitals escalating simultaneously -> RED
223
+
224
+ The metrics above reflect the NEURAL MODEL ONLY (without clinical rules).
225
+ In deployment, the clinical rules provide an additional safety floor,
226
+ meaning real-world recall for high-risk cases is higher than shown above.
227
+
228
+ ──────────────────────────────────────────────────────────────────────────
229
+ LIMITATIONS
230
+ ──────────────────────────────────────────────────────────────────────────
231
+ 1. SMALL DATASET: Trained on 1,014 rows from a single UCI dataset.
232
+ Real-world clinical models typically require 10,000–100,000+ samples.
233
+
234
+ 2. SYNTHETIC SEQUENCES: The UCI dataset has no patient IDs or timestamps.
235
+ We created artificial 5-visit sequences by sorting rows by age.
236
+ These do not represent real patient trajectories.
237
+
238
+ 3. NOT CLINICALLY VALIDATED: This model has NOT been validated against
239
+ real patient outcomes in a clinical setting. It must NOT be used
240
+ for actual medical decisions without proper clinical validation trials.
241
+
242
+ 4. POPULATION BIAS: The UCI dataset was collected from a specific
243
+ population. Performance may differ on patients from different
244
+ regions, ethnicities, or healthcare contexts.
245
+
246
+ 5. RESEARCH PROTOTYPE: This is a proof-of-concept demonstrating the
247
+ application of Mamba3 SSMs to maternal health risk prediction.
248
+ The system design (alarm fatigue mitigation, resource-aware routing,
249
+ OCR auto-fill) represents the primary contribution of this work.
250
+
251
+ ──────────────────────────────────────────────────────────────────────────
252
+ CITATION
253
+ ──────────────────────────────────────────────────────────────────────────
254
+ If you use this work, please cite:
255
+ MamaGuard: Maternal Mortality Early Warning using Mamba3 Sequential
256
+ State-Space Models with Clinical Safety Rules.
257
+ [Your Name], 2025. GitHub: [your-repo-url]
258
+ Based on: Gu & Dao (2023) Mamba; UCI Maternal Health Risk dataset.
259
+
260
+ ══════════════════════════════════════════════════════════════════════════
261
+ """
262
+ return report
263
+
264
+
265
+ # ── Main ──────────────────────────────────────────────────────────────────────
266
+
267
+ def evaluate():
268
+ print("\n" + "="*60)
269
+ print(" MamaGuard -- Model Evaluation")
270
+ print("="*60 + "\n")
271
+
272
+ for path, name in [(MODEL_PATH, "model"), (SCALER_PATH, "scaler"), (CSV_PATH, "dataset")]:
273
+ if not os.path.exists(path):
274
+ print(f"ERROR: {name} not found at {path}")
275
+ print("Run python -m src.train first.")
276
+ return
277
+
278
+ device = "cuda" if torch.cuda.is_available() else "cpu"
279
+ print(f"Device: {device}")
280
+
281
+ # Load data
282
+ print("\nLoading and preprocessing data...")
283
+ (X_train, y_train, q_train,
284
+ X_val, y_val, q_val,
285
+ scaler) = load_and_preprocess(CSV_PATH)
286
+
287
+ n_train = len(X_train)
288
+ n_val = len(X_val)
289
+
290
+ print(f"Validation set: {n_val} sequences")
291
+ print(f"Class distribution in validation: "
292
+ f"Low={sum(y_val==0)} Mid={sum(y_val==1)} High={sum(y_val==2)}")
293
+
294
+ # Load model and predict
295
+ print("\nLoading model...")
296
+ model = load_model(device)
297
+
298
+ print("Running inference on validation set...")
299
+ y_pred, y_proba = get_predictions(model, X_val, device)
300
+
301
+ # Compute metrics
302
+ print("\nComputing metrics...")
303
+ overall_acc = accuracy_score(y_val, y_pred)
304
+ cm = confusion_matrix(y_val, y_pred)
305
+ class_report = classification_report(
306
+ y_val, y_pred,
307
+ target_names=CLASS_NAMES,
308
+ digits=3
309
+ )
310
+
311
+ print(f"\n{'-'*50}")
312
+ print(f" Overall Accuracy: {overall_acc:.3f} ({overall_acc*100:.1f}%)")
313
+ print(f"{'-'*50}")
314
+ print("\nPer-class metrics:")
315
+ print(class_report)
316
+
317
+ print("Confusion matrix (rows=true, cols=predicted):")
318
+ header = f"{'':15s} {'Low':>8s} {'Mid':>8s} {'High':>8s}"
319
+ print(header)
320
+ for i, name in enumerate(CLASS_NAMES):
321
+ row = f" {name:13s} " + " ".join(f"{cm[i,j]:8d}" for j in range(3))
322
+ print(row)
323
+
324
+ hr_recall = cm[2, 2] / max(cm[2, :].sum(), 1)
325
+ print(f"\n** HIGH RISK RECALL: {hr_recall:.3f}")
326
+ if hr_recall < 0.60:
327
+ print(" [!] WARNING: Less than 60% of high-risk patients detected by model alone.")
328
+ print(" The WHO clinical rules provide the safety floor in deployment.")
329
+ elif hr_recall < 0.75:
330
+ print(" [!] Moderate. Consider retraining with more data (Path B).")
331
+ else:
332
+ print(" [OK] Good recall -- model is learning the high-risk pattern.")
333
+
334
+ # Save plots
335
+ print(f"\nSaving evaluation plots...")
336
+ os.makedirs("models", exist_ok=True)
337
+ plot_confusion_matrix(cm, CM_PATH)
338
+ plot_roc_curves(y_val, y_proba, ROC_PATH)
339
+
340
+ # Save text report
341
+ report_text = build_text_report(
342
+ y_val, y_pred, y_proba,
343
+ class_report, cm, n_train, n_val
344
+ )
345
+ with open(REPORT_PATH, "w", encoding="utf-8") as f:
346
+ f.write(report_text)
347
+ print(f" Full report saved -> {REPORT_PATH}")
348
+
349
+ # Final summary
350
+ print(f"\n{'='*60}")
351
+ print(" EVALUATION COMPLETE")
352
+ print(f"{'='*60}")
353
+ print(f"\n Files saved:")
354
+ print(f" {REPORT_PATH} <- paste into Hugging Face model card")
355
+ print(f" {CM_PATH} <- include in LinkedIn post")
356
+ print(f" {ROC_PATH} <- include in GitHub README")
357
+
358
+ print(f"\n Overall accuracy : {overall_acc*100:.1f}%")
359
+ print(f" High-risk recall : {hr_recall*100:.1f}%")
360
+
361
+ if hr_recall < 0.60:
362
+ print("\n RECOMMENDATION: Retrain with augmented data before publishing.")
363
+ print(" Current model relies heavily on WHO clinical rules for safety.")
364
+ print(" This is still publishable as a research prototype -- be transparent.")
365
+ else:
366
+ print("\n RECOMMENDATION: Model is ready to publish as research prototype.")
367
+ print(" Include the evaluation_report.txt in your model card.")
368
+
369
+ print()
370
+
371
+
372
+ if __name__ == "__main__":
373
+ evaluate()
src/explainability.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — Explainability
3
+ Gradient-based attribution for per-feature and per-visit importance.
4
+ """
5
+
6
+ import torch
7
+ import torch.nn.functional as F
8
+ import numpy as np
9
+ from src.model import MamaGuardMamba3
10
+
11
+ FEATURE_NAMES = ['Age', 'SystolicBP', 'DiastolicBP', 'BloodSugar', 'BodyTemp', 'HeartRate']
12
+ RISK_LABELS = ['Low risk', 'Medium risk', 'High risk']
13
+ RISK_EMOJI = ['🟢', '🟡', '🔴']
14
+
15
+
16
+ def explain_prediction(
17
+ model: MamaGuardMamba3,
18
+ x_sequence: np.ndarray,
19
+ scaler,
20
+ device: str = "cpu"
21
+ ) -> dict:
22
+ """
23
+ Runs the model on one patient and returns an explanation dict with:
24
+ risk_level, probabilities, confidence, top_reasons,
25
+ feature_importance, and visit_importance.
26
+ """
27
+ model.eval()
28
+
29
+ x_tensor = torch.tensor(x_sequence, dtype=torch.float32).unsqueeze(0).to(device)
30
+ x_tensor.requires_grad_(True)
31
+
32
+ # Forward pass
33
+ logits = model(x_tensor)
34
+ probs = F.softmax(logits, dim=-1)
35
+
36
+ pred_class = probs.argmax(dim=-1).item()
37
+ confidence = probs[0, pred_class].item()
38
+
39
+ # Gradient attribution
40
+ score = logits[0, pred_class]
41
+ score.backward()
42
+
43
+ grads = x_tensor.grad[0].cpu().numpy()
44
+ attribution = np.abs(grads)
45
+
46
+ # Per-feature importance (average over visits)
47
+ feature_importance = attribution.mean(axis=0)
48
+ feature_importance = feature_importance / (feature_importance.sum() + 1e-9)
49
+
50
+ # Per-visit importance (average over features)
51
+ visit_importance = attribution.mean(axis=1)
52
+ visit_importance = visit_importance / (visit_importance.sum() + 1e-9)
53
+
54
+ # Build human-readable top reasons
55
+ sorted_features = sorted(
56
+ zip(FEATURE_NAMES, feature_importance),
57
+ key=lambda x: x[1], reverse=True
58
+ )
59
+ top_reasons = []
60
+
61
+ x_orig = scaler.inverse_transform(x_sequence)
62
+
63
+ for feat, importance in sorted_features[:2]:
64
+ feat_idx = FEATURE_NAMES.index(feat)
65
+ vals = x_orig[:, feat_idx]
66
+ trend = vals[-1] - vals[0]
67
+
68
+ if abs(trend) > 0.5:
69
+ direction = "rising" if trend > 0 else "falling"
70
+ top_reasons.append(
71
+ f"{feat} is {direction} (from {vals[0]:.1f} to {vals[-1]:.1f})"
72
+ )
73
+ else:
74
+ top_reasons.append(
75
+ f"{feat} is consistently elevated (avg {vals.mean():.1f})"
76
+ )
77
+
78
+ # Assemble result
79
+ probs_np = probs[0].detach().cpu().numpy()
80
+
81
+ return {
82
+ "risk_level": RISK_LABELS[pred_class],
83
+ "risk_emoji": RISK_EMOJI[pred_class],
84
+ "probabilities": {
85
+ label: round(float(p), 4)
86
+ for label, p in zip(RISK_LABELS, probs_np)
87
+ },
88
+ "confidence": round(confidence, 4),
89
+ "top_reasons": top_reasons,
90
+ "feature_importance": {
91
+ feat: round(float(imp), 4)
92
+ for feat, imp in zip(FEATURE_NAMES, feature_importance)
93
+ },
94
+ "visit_importance": [round(float(v), 4) for v in visit_importance],
95
+ }
src/model.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — Mamba3 Model
3
+ Trapezoidal SSM with MIMO expansion and complex-valued state.
4
+ """
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+
10
+
11
+ class Mamba3SSMLayer(nn.Module):
12
+ """Core recurrent SSM engine of one Mamba3 block."""
13
+
14
+ def __init__(self, d_model: int, d_state: int = 32, expand: int = 2):
15
+ super().__init__()
16
+ self.d_model = d_model
17
+ self.d_state = d_state
18
+ self.d_inner = d_model * expand
19
+
20
+ # Input/output projections (MIMO)
21
+ self.in_proj = nn.Linear(d_model, self.d_inner * 2, bias=False)
22
+ self.out_proj = nn.Linear(self.d_inner, d_model, bias=False)
23
+
24
+ # Local depthwise convolution
25
+ self.conv1d = nn.Conv1d(
26
+ in_channels=self.d_inner,
27
+ out_channels=self.d_inner,
28
+ kernel_size=3,
29
+ padding=1,
30
+ groups=self.d_inner,
31
+ bias=True
32
+ )
33
+
34
+ # SSM parameters
35
+ self.A_log = nn.Parameter(torch.randn(self.d_inner, d_state))
36
+ self.D = nn.Parameter(torch.ones(self.d_inner))
37
+
38
+ # Input-dependent (selective) parameters: B, C, and Δ
39
+ self.x_proj = nn.Linear(self.d_inner, d_state * 2 + 1, bias=False)
40
+ self.dt_proj = nn.Linear(1, self.d_inner, bias=True)
41
+
42
+ # Trapezoidal blending parameter (α)
43
+ self.alpha = nn.Parameter(torch.tensor(0.5))
44
+
45
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
46
+ """x: (batch_size, seq_len, d_model) -> same shape output."""
47
+ B, L, _ = x.shape
48
+
49
+ # Project to inner dimension + gating signal
50
+ xz = self.in_proj(x)
51
+ x_in, z = xz.chunk(2, dim=-1)
52
+
53
+ # Local convolution + SiLU activation
54
+ x_conv = self.conv1d(x_in.transpose(1, 2)).transpose(1, 2)
55
+ x_conv = F.silu(x_conv)
56
+
57
+ # Compute input-dependent SSM parameters
58
+ dt_raw, B_ssm, C_ssm = self.x_proj(x_conv).split(
59
+ [1, self.d_state, self.d_state], dim=-1
60
+ )
61
+
62
+ dt = F.softplus(self.dt_proj(dt_raw))
63
+ A_real = -torch.exp(self.A_log)
64
+ alpha = torch.sigmoid(self.alpha)
65
+
66
+ # SSM recurrence
67
+ h = torch.zeros(B, self.d_inner, self.d_state, device=x.device)
68
+ outputs = []
69
+
70
+ for t in range(L):
71
+ dt_t = dt[:, t, :].unsqueeze(-1)
72
+ B_t = B_ssm[:, t, :].unsqueeze(1)
73
+ C_t = C_ssm[:, t, :].unsqueeze(1)
74
+ u_t = x_conv[:, t, :]
75
+
76
+ # Trapezoidal discretization: blend ZOH + Implicit Euler
77
+ A_d_zoh = torch.exp(A_real * dt_t)
78
+ A_d_euler = 1.0 / (1.0 - A_real * dt_t * 0.5 + 1e-6)
79
+ A_d = alpha * A_d_zoh + (1.0 - alpha) * A_d_euler
80
+
81
+ # State update + output
82
+ h = A_d * h + dt_t * B_t * u_t.unsqueeze(-1)
83
+ y_t = (C_t * h).sum(dim=-1) + self.D * u_t
84
+ outputs.append(y_t)
85
+
86
+ y = torch.stack(outputs, dim=1)
87
+
88
+ # Apply gating and project back
89
+ y = y * F.silu(z)
90
+ return self.out_proj(y)
91
+
92
+
93
+ class Mamba3Block(nn.Module):
94
+ """One complete Mamba3 processing block: LayerNorm -> SSM -> LayerNorm -> FFN."""
95
+
96
+ def __init__(self, d_model: int, d_state: int = 32):
97
+ super().__init__()
98
+ self.norm1 = nn.LayerNorm(d_model)
99
+ self.ssm = Mamba3SSMLayer(d_model, d_state)
100
+ self.norm2 = nn.LayerNorm(d_model)
101
+
102
+ self.ffn = nn.Sequential(
103
+ nn.Linear(d_model, d_model * 4),
104
+ nn.GELU(),
105
+ nn.Linear(d_model * 4, d_model),
106
+ nn.Dropout(p=0.1)
107
+ )
108
+
109
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
110
+ x = x + self.ssm(self.norm1(x))
111
+ x = x + self.ffn(self.norm2(x))
112
+ return x
113
+
114
+
115
+ class MamaGuardMamba3(nn.Module):
116
+ """
117
+ Complete MamaGuard model.
118
+ Flow: raw vitals (6) -> embed -> 4 Mamba3 blocks -> pool -> classify (3 classes)
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ input_dim: int = 6,
124
+ d_model: int = 64,
125
+ n_layers: int = 4,
126
+ n_classes: int = 3,
127
+ d_state: int = 32,
128
+ ):
129
+ super().__init__()
130
+
131
+ self.input_proj = nn.Sequential(
132
+ nn.Linear(input_dim, d_model),
133
+ nn.LayerNorm(d_model),
134
+ )
135
+
136
+ self.blocks = nn.ModuleList([
137
+ Mamba3Block(d_model, d_state) for _ in range(n_layers)
138
+ ])
139
+
140
+ self.norm_out = nn.LayerNorm(d_model)
141
+
142
+ self.classifier = nn.Sequential(
143
+ nn.Linear(d_model, d_model // 2),
144
+ nn.GELU(),
145
+ nn.Dropout(p=0.2),
146
+ nn.Linear(d_model // 2, n_classes)
147
+ )
148
+
149
+ def forward(self, x: torch.Tensor, return_features: bool = False):
150
+ """
151
+ x: (batch_size, seq_len, input_dim)
152
+ Returns: logits (batch_size, n_classes)
153
+ """
154
+ x = self.input_proj(x)
155
+
156
+ for block in self.blocks:
157
+ x = block(x)
158
+
159
+ x = self.norm_out(x)
160
+ features = x.mean(dim=1) # global average pool over time
161
+ logits = self.classifier(features)
162
+
163
+ if return_features:
164
+ return logits, features
165
+ return logits
166
+
167
+ def predict_proba(self, x: torch.Tensor):
168
+ """Returns probabilities (after softmax) instead of logits."""
169
+ with torch.no_grad():
170
+ logits = self.forward(x)
171
+ return F.softmax(logits, dim=-1)
src/predict.py ADDED
File without changes
src/train.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MamaGuard — Training Loop
3
+ Trains the Mamba3 model with class-weighted loss and LR scheduling.
4
+ """
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.optim as optim
9
+ import numpy as np
10
+ import pickle
11
+ import os
12
+ from src.model import MamaGuardMamba3
13
+ from src.data_pipeline import get_dataloaders
14
+
15
+ MODEL_SAVE_PATH = "models/mamaguard_mamba3.pt"
16
+ SCALER_SAVE_PATH = "models/scaler.pkl"
17
+ os.makedirs("models", exist_ok=True)
18
+
19
+
20
+ def compute_class_weights(train_loader):
21
+ """Compute inverse-frequency class weights with 3× boost for high-risk."""
22
+ all_labels = []
23
+ for _, y, _ in train_loader:
24
+ all_labels.extend(y.numpy())
25
+
26
+ counts = np.bincount(all_labels, minlength=3)
27
+ total = counts.sum()
28
+
29
+ weights = total / (3 * counts + 1e-6)
30
+ weights[2] *= 3.0 # high-risk class boost
31
+ print(f"Class weights: LOW={weights[0]:.2f}, MID={weights[1]:.2f}, HIGH={weights[2]:.2f}")
32
+ return torch.tensor(weights, dtype=torch.float32)
33
+
34
+
35
+ def train(
36
+ csv_path: str = "data/maternal_health.csv",
37
+ epochs: int = 50,
38
+ batch_size: int = 32,
39
+ lr: float = 1e-3,
40
+ device: str = None
41
+ ):
42
+ """Full training loop with validation and best-model checkpointing."""
43
+ if device is None:
44
+ device = "cuda" if torch.cuda.is_available() else "cpu"
45
+ print(f"Training on: {device}")
46
+
47
+ # Load data
48
+ train_loader, val_loader, scaler = get_dataloaders(csv_path, batch_size)
49
+
50
+ with open(SCALER_SAVE_PATH, "wb") as f:
51
+ pickle.dump(scaler, f)
52
+ print(f"Scaler saved to {SCALER_SAVE_PATH}")
53
+
54
+ # Build model
55
+ model = MamaGuardMamba3(
56
+ input_dim=6, d_model=64, n_layers=4, n_classes=3, d_state=32
57
+ ).to(device)
58
+
59
+ total_params = sum(p.numel() for p in model.parameters())
60
+ print(f"Model parameters: {total_params:,}")
61
+
62
+ # Loss function with class weights
63
+ class_weights = compute_class_weights(train_loader).to(device)
64
+ criterion = nn.CrossEntropyLoss(weight=class_weights)
65
+
66
+ # Optimizer + scheduler
67
+ optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
68
+ scheduler = optim.lr_scheduler.ReduceLROnPlateau(
69
+ optimizer, mode="min", factor=0.5, patience=5,
70
+ )
71
+
72
+ # Training loop
73
+ best_val_loss = float("inf")
74
+ best_val_acc = 0.0
75
+
76
+ for epoch in range(1, epochs + 1):
77
+ # Training phase
78
+ model.train()
79
+ train_loss, train_correct, train_total = 0.0, 0, 0
80
+
81
+ for X_batch, y_batch, _ in train_loader:
82
+ X_batch = X_batch.to(device)
83
+ y_batch = y_batch.to(device)
84
+
85
+ optimizer.zero_grad()
86
+ logits = model(X_batch)
87
+ loss = criterion(logits, y_batch)
88
+ loss.backward()
89
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
90
+ optimizer.step()
91
+
92
+ train_loss += loss.item()
93
+ preds = logits.argmax(dim=-1)
94
+ train_correct += (preds == y_batch).sum().item()
95
+ train_total += len(y_batch)
96
+
97
+ # Validation phase
98
+ model.eval()
99
+ val_loss, val_correct, val_total = 0.0, 0, 0
100
+
101
+ with torch.no_grad():
102
+ for X_batch, y_batch, _ in val_loader:
103
+ X_batch = X_batch.to(device)
104
+ y_batch = y_batch.to(device)
105
+ logits = model(X_batch)
106
+ loss = criterion(logits, y_batch)
107
+ val_loss += loss.item()
108
+ preds = logits.argmax(dim=-1)
109
+ val_correct += (preds == y_batch).sum().item()
110
+ val_total += len(y_batch)
111
+
112
+ avg_train_loss = train_loss / len(train_loader)
113
+ avg_val_loss = val_loss / len(val_loader)
114
+ train_acc = train_correct / train_total
115
+ val_acc = val_correct / val_total
116
+
117
+ print(
118
+ f"Epoch {epoch:3d}/{epochs} | "
119
+ f"Train Loss: {avg_train_loss:.4f} Acc: {train_acc:.3f} | "
120
+ f"Val Loss: {avg_val_loss:.4f} Acc: {val_acc:.3f}"
121
+ )
122
+
123
+ scheduler.step(avg_val_loss)
124
+
125
+ if val_acc > best_val_acc:
126
+ best_val_acc = val_acc
127
+ best_val_loss = avg_val_loss
128
+ torch.save(model.state_dict(), MODEL_SAVE_PATH)
129
+ print(f" * Best model saved (val_acc={val_acc:.3f})")
130
+
131
+ print(f"\nTraining complete. Best val accuracy: {best_val_acc:.3f}")
132
+ print(f"Model saved to: {MODEL_SAVE_PATH}")
133
+ return model
134
+
135
+
136
+ if __name__ == "__main__":
137
+ train()