tavi-backend / src /tavi_api /scoring /drivers.py
Jainish Solanki
Initial backend Space
84da040
Raw
History Blame Contribute Delete
5.32 kB
"""Render SHAP top-5 contributions in clinical language with modifiable flags.
Maps raw feature names (e.g., `albumin_g_dl`) to clinician-friendly labels
(e.g., "Albumin 2.9 g/dL") and tags whether the factor is modifiable before
the procedure.
Modifiable factors (as defined in this prototype):
- Albumin (nutritional optimization)
- Hemoglobin (treat anemia)
- Gait speed (PT/rehab)
- NYHA class (medical optimization)
"""
from __future__ import annotations
from collections.abc import Sequence
from tavi_api.schemas import RiskScoreDriver, ShapContribution
# Sigmoid scale-factor for converting log-odds SHAP values to approximate
# probability impact (in percentage points). Uses the working-point slope of
# the logistic at the cohort base rate (~3-5%): dp/dlogit ≈ p*(1-p).
_BASE_RATE = 0.04
_LOGIT_TO_PP = _BASE_RATE * (1 - _BASE_RATE) * 100.0 # ≈ 3.84 pp per logit unit
_CLINICAL_LABELS: dict[str, tuple[str, str]] = {
# name -> (display_label_template, unit)
"age_years": ("Age", "yrs"),
"sex_female": ("Sex", ""),
"bmi": ("BMI", "kg/m²"),
"lvef_pct": ("LVEF", "%"),
"egfr": ("eGFR", "mL/min/1.73m²"),
"creatinine_mg_dl": ("Creatinine", "mg/dL"),
"hemoglobin_g_dl": ("Hemoglobin", "g/dL"),
"albumin_g_dl": ("Albumin", "g/dL"),
"nt_probnp_pg_ml": ("NT-proBNP", "pg/mL"),
"diabetes": ("Diabetes", ""),
"chronic_lung_disease": ("COPD / lung disease", ""),
"prior_mi": ("Prior MI", ""),
"prior_pci": ("Prior PCI", ""),
"prior_cabg": ("Prior CABG", ""),
"prior_stroke": ("Prior stroke", ""),
"peripheral_vascular_disease": ("Peripheral vascular disease", ""),
"atrial_fibrillation": ("Atrial fibrillation", ""),
"prior_pacemaker": ("Prior pacemaker", ""),
"on_dialysis": ("On dialysis", ""),
"nyha_class_num": ("NYHA class", ""),
"urgency_num": ("Urgency", ""),
"gait_speed_m_per_s": ("Gait speed", "m/s"),
"annular_area_mm2": ("Annular area", "mm²"),
"calcium_volume_au": ("Aortic-valve calcium", "AU"),
"membranous_septum_length_mm": ("Membranous septum", "mm"),
"distance_to_left_main_mm": ("Left-main height", "mm"),
}
_MODIFIABLE: dict[str, str] = {
"albumin_g_dl": "≥ 3.5 g/dL before procedure (nutrition consult)",
"hemoglobin_g_dl": "≥ 12 g/dL (treat anemia: iron / EPO / transfusion if appropriate)",
"gait_speed_m_per_s": "≥ 0.85 m/s (pre-habilitation, PT)",
"nyha_class_num": "≤ NYHA II (optimize HF therapy: GDMT, diuretics)",
}
def _format_value(name: str, raw_value) -> str:
"""Render a feature value as a human-readable string."""
if name in {
"sex_female",
"diabetes",
"chronic_lung_disease",
"prior_mi",
"prior_pci",
"prior_cabg",
"prior_stroke",
"peripheral_vascular_disease",
"atrial_fibrillation",
"prior_pacemaker",
"on_dialysis",
}:
return "yes" if bool(raw_value) else "no"
if name == "nyha_class_num":
try:
return ["I", "II", "III", "IV"][int(raw_value) - 1]
except (TypeError, ValueError, IndexError):
return str(raw_value)
if name == "urgency_num":
try:
return ["elective", "urgent", "emergent"][int(raw_value)]
except (TypeError, ValueError, IndexError):
return str(raw_value)
try:
return f"{float(raw_value):.1f}"
except (TypeError, ValueError):
return str(raw_value)
def _build_label(name: str, raw_value) -> str:
label_tmpl = _CLINICAL_LABELS.get(name, (name, ""))
label, unit = label_tmpl
if name == "sex_female":
return f"Sex: {'female' if bool(raw_value) else 'male'}"
value_str = _format_value(name, raw_value)
if unit:
return f"{label} {value_str} {unit}"
return f"{label}: {value_str}"
def to_clinical_drivers(
shap_top5: Sequence[ShapContribution] | Sequence[dict],
) -> list[RiskScoreDriver]:
"""Convert SHAP top-5 to clinical-language drivers with modifiable flags."""
drivers: list[RiskScoreDriver] = []
for s in shap_top5:
if isinstance(s, dict):
feature = s["feature"]
value = s.get("value")
shap_val = float(s["shap_value"])
direction = s["direction"]
else:
feature = s.feature
value = s.value
shap_val = float(s.shap_value)
direction = s.direction
magnitude_pp = abs(shap_val) * _LOGIT_TO_PP
drivers.append(
RiskScoreDriver(
feature_label=_build_label(feature, value),
raw_feature_name=feature,
current_value=_format_value(feature, value),
effect="increases" if direction == "increases" else "decreases",
magnitude_pp=round(magnitude_pp, 2),
is_modifiable=feature in _MODIFIABLE,
modify_to=_MODIFIABLE.get(feature),
)
)
return drivers
def top_modifiable(drivers: list[RiskScoreDriver]) -> RiskScoreDriver | None:
"""Return the highest-impact modifiable driver, or None."""
modifiables = [d for d in drivers if d.is_modifiable and d.effect == "increases"]
if not modifiables:
return None
return max(modifiables, key=lambda d: d.magnitude_pp)