prism-backend / src /risk_stratifier.py
Penguindrum920's picture
Prepare PRISM backend for Hugging Face Spaces
7bc5fde verified
Raw
History Blame Contribute Delete
4.54 kB
"""
Risk Stratifier — MDS Prodromal Markers + Bootstrap CI.
Maps available PPMI features to MDS prodromal markers, computes a
risk score, and produces bootstrap confidence intervals.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import numpy as np
logger = logging.getLogger(__name__)
MDS_MARKERS = {
"rem": {"weight": 2.5, "threshold_fn": "binary"},
"upsit": {"weight": 2.0, "threshold_fn": "upsit_low"},
"pigd": {"weight": 1.5, "threshold_fn": "pigd_present"},
"gds": {"weight": 1.2, "threshold_fn": "gds_high"},
"fampd_bin": {"weight": 1.0, "threshold_fn": "binary"},
}
def _check_marker(name: str, value: Optional[float]) -> Optional[float]:
if value is None:
return None
spec = MDS_MARKERS.get(name)
if spec is None:
return None
fn, w = spec["threshold_fn"], spec["weight"]
if fn == "binary":
return w if value >= 1.0 else 0.0
if fn == "upsit_low":
return w if value <= 22.0 else 0.0
if fn == "pigd_present":
return w if value >= 1.0 else 0.0
if fn == "gds_high":
return w if value >= 5.0 else 0.0
return 0.0
class RiskStratifier:
"""MDS criteria risk stratification with bootstrap CIs."""
def __init__(self, n_bootstrap: int = 100) -> None:
self.n_bootstrap = n_bootstrap
def stratify(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
marker_values: Dict[str, Optional[float]] = {}
for marker in MDS_MARKERS:
marker_values[marker] = self._extract(patient_data, marker)
contributions: Dict[str, Optional[float]] = {}
available: List[str] = []
for m, v in marker_values.items():
s = _check_marker(m, v)
contributions[m] = s
if s is not None:
available.append(m)
total = sum(v for v in contributions.values() if v is not None)
max_p = sum(MDS_MARKERS[m]["weight"] for m in available) if available else 1.0
raw_conf = total / max(max_p, 1e-6)
category = self._categorize(raw_conf, patient_data)
ci_lo, ci_hi = self._bootstrap_ci(marker_values, available)
return {
"category": category,
"confidence": round(float(raw_conf), 4),
"ci_lower": round(float(ci_lo), 4),
"ci_upper": round(float(ci_hi), 4),
"marker_contributions": contributions,
"total_score": round(float(total), 4),
"max_possible_score": round(float(max_p), 4),
}
def _bootstrap_ci(self, marker_values, available):
if not available:
return (0.0, 0.0)
rng = np.random.RandomState(42)
weights = np.array([MDS_MARKERS[m]["weight"] for m in available])
base = np.array([_check_marker(m, marker_values[m]) or 0.0 for m in available])
scores = []
for _ in range(self.n_bootstrap):
idx = rng.choice(len(available), size=len(available), replace=True)
conf = base[idx].sum() / max(weights[idx].sum(), 1e-6)
conf += rng.normal(0, 0.02)
scores.append(float(np.clip(conf, 0, 1)))
return (float(np.percentile(scores, 2.5)), float(np.percentile(scores, 97.5)))
def _categorize(self, confidence, patient_data):
sym_total = sum(
float(patient_data.get(s) or (patient_data.get("motor", {}) or {}).get(s) or 0)
for s in ("sym_tremor", "sym_rigid", "sym_brady", "sym_posins")
)
updrs3 = (patient_data.get("motor", {}) or {}).get("updrs3_score") or patient_data.get("updrs3_score")
if confidence >= 0.55 or sym_total >= 5 or (updrs3 is not None and float(updrs3) >= 20):
return "PD"
if confidence >= 0.30:
return "Prodromal PD"
if confidence >= 0.15:
return "SWEDD"
return "HC"
@staticmethod
def _extract(data: Dict[str, Any], marker: str) -> Optional[float]:
val = data.get(marker)
if val is not None:
try:
return float(val)
except (TypeError, ValueError):
pass
for sec in ("non_motor", "motor", "cognition", "autonomic"):
sub = data.get(sec)
if isinstance(sub, dict) and marker in sub and sub[marker] is not None:
try:
return float(sub[marker])
except (TypeError, ValueError):
pass
return None