mmrech commited on
Commit ·
a36bd47
1
Parent(s): d6b3520
v5.0: Add PDP tab, JSON API endpoints, SHAP in mobile HTML
Browse files- app.py +292 -57
- evb_prognosis_mobile.html +167 -6
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
EVB Prognosis: 1-Year Mortality Risk Calculator
|
| 3 |
Calibrated Random Forest with Isotonic Calibration (AUC 0.915)
|
| 4 |
|
| 5 |
Architecture:
|
|
@@ -7,8 +7,10 @@ Architecture:
|
|
| 7 |
- JSON isotonic calibration data
|
| 8 |
- Fallback to joblib if ONNX unavailable
|
| 9 |
- SHAP feature importance (global + patient-specific)
|
|
|
|
| 10 |
- Glassmorphism dark theme with classic toggle
|
| 11 |
- Landing page with device detection
|
|
|
|
| 12 |
"""
|
| 13 |
import os
|
| 14 |
import gradio as gr
|
|
@@ -98,22 +100,47 @@ for fn in FEATURE_NAMES:
|
|
| 98 |
}
|
| 99 |
CLEAN_NAMES[fn] = mappings.get(clean, clean.replace("_", " ").title())
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
# Precompute global feature importance at startup
|
| 102 |
GLOBAL_IMPORTANCE = None
|
|
|
|
| 103 |
if SHAP_AVAILABLE:
|
| 104 |
try:
|
| 105 |
-
# Use a representative set of patients to compute global importance
|
| 106 |
representative_patients = [
|
| 107 |
-
|
| 108 |
-
"hepatorenal_syndrome": "no", "omeprazole": "no", "spironolactone": "yes",
|
| 109 |
-
"furosemide": "yes", "propanolol": "no", "dialisis": "no",
|
| 110 |
-
"portal_vein_thrombosis": "no", "ascitis": "yes", "hepatocellular_carcinoma": "no",
|
| 111 |
-
"albumin": 3.5, "total_bilirrubin": 2.0, "direct_bilirrubina": 0.5,
|
| 112 |
-
"inr": 1.2, "creatinine": 1.0, "platelets": 150, "ast": 35, "alt": 25,
|
| 113 |
-
"hemoglobin": 13, "hematocrit": 40, "leucocytes": 6, "sodium": 140,
|
| 114 |
-
"potassium": 4, "varices": "yes", "red_wale_marks": "no",
|
| 115 |
-
"rupture_point": "no", "active_bleeding": "no", "therapy": "Banding",
|
| 116 |
-
"terlipressin_dose": 2, "time-to-endoscophy_hours": 12, "rebleeding": "no"},
|
| 117 |
{"age": 75, "sex": "female", "race": "black", "etiology_cirrosis": "hcv",
|
| 118 |
"hepatorenal_syndrome": "yes", "omeprazole": "yes", "spironolactone": "no",
|
| 119 |
"furosemide": "no", "propanolol": "yes", "dialisis": "yes",
|
|
@@ -144,19 +171,26 @@ if SHAP_AVAILABLE:
|
|
| 144 |
rf = cc.base_estimator
|
| 145 |
explainer = shap.TreeExplainer(rf)
|
| 146 |
sv = explainer.shap_values(proc)
|
| 147 |
-
# sv shape: (1, n_features, 2) — index [:, :, 1] for death class
|
| 148 |
shap_vals = np.array(sv)[0, :, 1]
|
| 149 |
global_shap_accum += np.abs(shap_vals)
|
| 150 |
|
| 151 |
-
n_total = len(representative_patients) * 5
|
| 152 |
GLOBAL_IMPORTANCE = global_shap_accum / n_total
|
| 153 |
|
| 154 |
-
#
|
| 155 |
sorted_idx = np.argsort(GLOBAL_IMPORTANCE)[::-1]
|
| 156 |
-
|
| 157 |
-
for i in range(min(
|
| 158 |
idx = int(sorted_idx[i])
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
print("Global SHAP computed successfully")
|
| 161 |
except Exception as e:
|
| 162 |
print(f"Error computing global SHAP: {e}")
|
|
@@ -164,6 +198,60 @@ if SHAP_AVAILABLE:
|
|
| 164 |
traceback.print_exc()
|
| 165 |
GLOBAL_IMPORTANCE = None
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def compute_patient_shap(processed):
|
| 168 |
"""Compute patient-specific SHAP values averaged across 5 folds."""
|
| 169 |
if not SHAP_AVAILABLE:
|
|
@@ -236,12 +324,10 @@ def render_patient_shap_html(shap_values, base_value, probability):
|
|
| 236 |
|
| 237 |
if val > 0:
|
| 238 |
color = "#ff4b2b"
|
| 239 |
-
direction = "RISK"
|
| 240 |
arrow = "+"
|
| 241 |
bar_style = f"width:{abs_pct:.1f}%; margin-left:50%; background:linear-gradient(90deg, rgba(255,75,43,0.3), rgba(255,75,43,0.7));"
|
| 242 |
else:
|
| 243 |
color = "#00e5a0"
|
| 244 |
-
direction = "PROTECTIVE"
|
| 245 |
arrow = ""
|
| 246 |
bar_style = f"width:{abs_pct:.1f}%; margin-left:{50 - abs_pct/2:.1f}%; background:linear-gradient(90deg, rgba(0,229,160,0.7), rgba(0,229,160,0.3));"
|
| 247 |
|
|
@@ -255,7 +341,6 @@ def render_patient_shap_html(shap_values, base_value, probability):
|
|
| 255 |
<div style="width:65px; font-size:10px; color:{color}; font-weight:600; font-family:monospace;">{arrow}{val:.4f}</div>
|
| 256 |
</div>"""
|
| 257 |
|
| 258 |
-
# Summary
|
| 259 |
total_shap = float(np.sum(shap_values))
|
| 260 |
direction_text = "above" if total_shap > 0 else "below"
|
| 261 |
direction_color = "#ff4b2b" if total_shap > 0 else "#00e5a0"
|
|
@@ -280,6 +365,125 @@ def render_patient_shap_html(shap_values, base_value, probability):
|
|
| 280 |
</div>
|
| 281 |
</div>"""
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
# ===== Clinical Score Calculations =====
|
| 284 |
def calculate_meld(bilirubin, inr, creatinine):
|
| 285 |
bilirubin = max(bilirubin, 1.0)
|
|
@@ -312,7 +516,6 @@ def calculate_albi(bilirubin, albumin):
|
|
| 312 |
return round(albi, 2), grade
|
| 313 |
|
| 314 |
def apply_isotonic(raw_prob, x_thresholds, y_thresholds):
|
| 315 |
-
"""Apply isotonic calibration using linear interpolation."""
|
| 316 |
return float(np.interp(raw_prob, x_thresholds, y_thresholds))
|
| 317 |
|
| 318 |
# ===== Prediction Function =====
|
|
@@ -349,7 +552,6 @@ def predict_patient_outcome(
|
|
| 349 |
df = pd.DataFrame([input_data])
|
| 350 |
processed = preprocessor.transform(df)
|
| 351 |
|
| 352 |
-
# ML prediction
|
| 353 |
if USE_ONNX:
|
| 354 |
processed_f32 = processed.astype(np.float32)
|
| 355 |
fold_probs = []
|
|
@@ -367,21 +569,17 @@ def predict_patient_outcome(
|
|
| 367 |
probability = joblib_model.predict_proba(processed)[:, 1][0]
|
| 368 |
inference_engine = "joblib"
|
| 369 |
|
| 370 |
-
# SHAP computation
|
| 371 |
patient_shap, base_value = compute_patient_shap(processed)
|
| 372 |
|
| 373 |
-
# Confidence interval (simplified)
|
| 374 |
confidence_margin = 0.15
|
| 375 |
ci_lower = max(0, probability - confidence_margin)
|
| 376 |
ci_upper = min(1, probability + confidence_margin)
|
| 377 |
|
| 378 |
-
# Traditional scores
|
| 379 |
meld = calculate_meld(total_bilirrubin, inr, creatinine)
|
| 380 |
meld_na = calculate_meld_na(total_bilirrubin, inr, creatinine, sodium)
|
| 381 |
child_pugh, cp_class = calculate_child_pugh(total_bilirrubin, albumin, inr, ascitis)
|
| 382 |
albi, albi_grade = calculate_albi(total_bilirrubin, albumin)
|
| 383 |
|
| 384 |
-
# Risk category
|
| 385 |
if probability < 0.3:
|
| 386 |
risk_category, risk_icon, risk_bar_color = "LOW RISK", "OK", "#00e5a0"
|
| 387 |
elif probability < 0.6:
|
|
@@ -392,7 +590,6 @@ def predict_patient_outcome(
|
|
| 392 |
pct = probability * 100
|
| 393 |
bar_width = int(pct)
|
| 394 |
|
| 395 |
-
# Styled ML output
|
| 396 |
ml_output = f"""
|
| 397 |
<div style="text-align:center; padding:10px 0;">
|
| 398 |
<div style="font-size:48px; font-weight:800; background: linear-gradient(135deg, {risk_bar_color}, #fff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; letter-spacing:2px;">{pct:.1f}%</div>
|
|
@@ -407,7 +604,6 @@ def predict_patient_outcome(
|
|
| 407 |
</div>
|
| 408 |
"""
|
| 409 |
|
| 410 |
-
# Styled traditional scores
|
| 411 |
traditional_scores = f"""
|
| 412 |
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; padding:8px 0;">
|
| 413 |
<div style="background:rgba(0,229,160,0.08); border:1px solid rgba(0,229,160,0.2); border-radius:12px; padding:16px; text-align:center;">
|
|
@@ -433,7 +629,6 @@ def predict_patient_outcome(
|
|
| 433 |
</div>
|
| 434 |
"""
|
| 435 |
|
| 436 |
-
# Styled comparison table
|
| 437 |
comparison = f"""
|
| 438 |
<div style="padding:8px 0;">
|
| 439 |
<table style="width:100%; border-collapse:separate; border-spacing:0 6px;">
|
|
@@ -477,21 +672,25 @@ def predict_patient_outcome(
|
|
| 477 |
</div>
|
| 478 |
"""
|
| 479 |
|
| 480 |
-
# SHAP outputs
|
| 481 |
global_shap_html = render_global_shap_html()
|
| 482 |
patient_shap_html = render_patient_shap_html(patient_shap, base_value, probability)
|
| 483 |
|
| 484 |
return ml_output, traditional_scores, comparison, global_shap_html, patient_shap_html
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
# ===== CSS Themes =====
|
| 487 |
GLASSMORPHISM_CSS = """
|
| 488 |
-
/* === Root dark background with subtle gradient === */
|
| 489 |
.gradio-container {
|
| 490 |
background: linear-gradient(135deg, #0a0e17 0%, #0d1520 30%, #0a1628 60%, #0f0a1a 100%) !important;
|
| 491 |
min-height: 100vh;
|
| 492 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
| 493 |
}
|
| 494 |
-
/* === Animated vapor cloud background === */
|
| 495 |
.gradio-container::before {
|
| 496 |
content: '';
|
| 497 |
position: fixed;
|
|
@@ -511,7 +710,6 @@ GLASSMORPHISM_CSS = """
|
|
| 511 |
75% { transform: translate(1%, -2%) rotate(0.5deg); }
|
| 512 |
}
|
| 513 |
.gradio-container > * { position: relative; z-index: 1; }
|
| 514 |
-
/* === Glass card effect === */
|
| 515 |
.block, .form, .panel {
|
| 516 |
background: rgba(15, 23, 42, 0.6) !important;
|
| 517 |
backdrop-filter: blur(20px) !important;
|
|
@@ -520,7 +718,6 @@ GLASSMORPHISM_CSS = """
|
|
| 520 |
border-radius: 16px !important;
|
| 521 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
|
| 522 |
}
|
| 523 |
-
/* === Tab styling === */
|
| 524 |
.tab-nav {
|
| 525 |
background: rgba(15, 23, 42, 0.4) !important;
|
| 526 |
border-radius: 12px !important; padding: 4px !important;
|
|
@@ -537,7 +734,6 @@ GLASSMORPHISM_CSS = """
|
|
| 537 |
color: #00e5a0 !important; font-weight: 600 !important;
|
| 538 |
box-shadow: 0 0 20px rgba(0, 229, 160, 0.1) !important;
|
| 539 |
}
|
| 540 |
-
/* === Input styling === */
|
| 541 |
input, select, textarea, .wrap {
|
| 542 |
background: rgba(15, 23, 42, 0.8) !important;
|
| 543 |
border: 1px solid rgba(0, 229, 160, 0.15) !important;
|
|
@@ -550,12 +746,10 @@ input:focus, select:focus, textarea:focus {
|
|
| 550 |
outline: none !important;
|
| 551 |
}
|
| 552 |
label, .label-wrap span { color: #9ba1a6 !important; font-weight: 500 !important; font-size: 13px !important; }
|
| 553 |
-
/* === Slider === */
|
| 554 |
input[type="range"] { background: transparent !important; border: none !important; }
|
| 555 |
input[type="range"]::-webkit-slider-track { background: rgba(0, 229, 160, 0.15) !important; height: 4px !important; border-radius: 2px !important; }
|
| 556 |
input[type="range"]::-webkit-slider-thumb { background: #00e5a0 !important; border: 2px solid rgba(0, 229, 160, 0.5) !important; box-shadow: 0 0 10px rgba(0, 229, 160, 0.3) !important; }
|
| 557 |
.range_input input[type="number"], input[type="number"] { background: rgba(15, 23, 42, 0.8) !important; color: #00e5a0 !important; font-weight: 600 !important; border: 1px solid rgba(0, 229, 160, 0.2) !important; }
|
| 558 |
-
/* === Primary button === */
|
| 559 |
.primary {
|
| 560 |
background: linear-gradient(135deg, #00e5a0 0%, #00b4d8 100%) !important;
|
| 561 |
border: none !important; color: #0a0e17 !important;
|
|
@@ -566,35 +760,23 @@ input[type="range"]::-webkit-slider-thumb { background: #00e5a0 !important; bord
|
|
| 566 |
transition: all 0.3s ease !important;
|
| 567 |
}
|
| 568 |
.primary:hover { box-shadow: 0 6px 30px rgba(0, 229, 160, 0.5) !important; transform: translateY(-1px) !important; }
|
| 569 |
-
/* === Markdown text === */
|
| 570 |
.markdown-text, .prose, .md { color: #ecedee !important; }
|
| 571 |
.markdown-text h1, .prose h1 { background: linear-gradient(135deg, #00e5a0, #00b4d8) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; font-weight: 800 !important; letter-spacing: 1px !important; }
|
| 572 |
.markdown-text h3, .prose h3 { color: #00e5a0 !important; font-weight: 600 !important; }
|
| 573 |
.markdown-text p, .prose p { color: #9ba1a6 !important; line-height: 1.7 !important; }
|
| 574 |
.markdown-text strong, .prose strong { color: #ecedee !important; }
|
| 575 |
-
/* === Dropdown === */
|
| 576 |
.wrap .secondary-wrap { background: rgba(15, 23, 42, 0.8) !important; border: 1px solid rgba(0, 229, 160, 0.15) !important; }
|
| 577 |
ul.options li { color: #ecedee !important; background: rgba(15, 23, 42, 0.95) !important; }
|
| 578 |
ul.options li:hover, ul.options li.selected { background: rgba(0, 229, 160, 0.15) !important; color: #00e5a0 !important; }
|
| 579 |
-
/* === Scrollbar === */
|
| 580 |
::-webkit-scrollbar { width: 6px; }
|
| 581 |
::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.3); }
|
| 582 |
::-webkit-scrollbar-thumb { background: rgba(0, 229, 160, 0.2); border-radius: 3px; }
|
| 583 |
::-webkit-scrollbar-thumb:hover { background: rgba(0, 229, 160, 0.4); }
|
| 584 |
-
/* === Footer === */
|
| 585 |
footer { display: none !important; }
|
| 586 |
.gap { gap: 12px !important; }
|
| 587 |
-
/* === Theme toggle button === */
|
| 588 |
-
#theme-toggle-btn {
|
| 589 |
-
position: fixed !important; top: 12px; right: 12px; z-index: 9999;
|
| 590 |
-
padding: 8px 16px; border-radius: 8px; cursor: pointer;
|
| 591 |
-
font-size: 12px; font-weight: 600; letter-spacing: 0.5px;
|
| 592 |
-
transition: all 0.3s ease;
|
| 593 |
-
}
|
| 594 |
"""
|
| 595 |
|
| 596 |
CLASSIC_OVERRIDE_CSS = """
|
| 597 |
-
/* Classic theme overrides - injected via JS toggle */
|
| 598 |
.classic-mode .gradio-container { background: #f7f8fa !important; }
|
| 599 |
.classic-mode .gradio-container::before { display: none !important; }
|
| 600 |
.classic-mode .block, .classic-mode .form, .classic-mode .panel {
|
|
@@ -649,7 +831,6 @@ THEME_JS = """
|
|
| 649 |
|
| 650 |
with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as demo:
|
| 651 |
|
| 652 |
-
# Theme toggle (JavaScript-based)
|
| 653 |
gr.HTML("""
|
| 654 |
<div id="theme-controls" style="position:fixed; top:12px; right:12px; z-index:9999; display:flex; gap:8px; align-items:center;">
|
| 655 |
<button id="theme-toggle" onclick="toggleTheme()" style="
|
|
@@ -665,7 +846,6 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 665 |
background:rgba(0,180,216,0.15); color:#00b4d8;
|
| 666 |
border:1px solid rgba(0,180,216,0.3);
|
| 667 |
text-decoration:none;
|
| 668 |
-
transition:all 0.3s ease;
|
| 669 |
">Mobile Version</a>
|
| 670 |
</div>
|
| 671 |
""")
|
|
@@ -735,6 +915,32 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 735 |
sodium = gr.Slider(minimum=120, maximum=160, step=1, label="Sodium (mEq/L)", value=140)
|
| 736 |
potassium = gr.Slider(minimum=2, maximum=6, step=0.1, label="Potassium (mEq/L)", value=4)
|
| 737 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
with gr.Tab("About"):
|
| 739 |
gr.Markdown("""
|
| 740 |
### Model Architecture & Validation
|
|
@@ -751,6 +957,12 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 751 |
- Prospective validation (n=24): AUC 0.927 (SD +/- 0.053)
|
| 752 |
- Superior performance vs. traditional scores (p < 0.001)
|
| 753 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
### Limitations
|
| 755 |
- Single-center development (external validation ongoing)
|
| 756 |
- Small prospective validation cohort (n=24)
|
|
@@ -773,7 +985,6 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 773 |
with gr.Row():
|
| 774 |
predict_btn = gr.Button("CALCULATE RISK ASSESSMENT", variant="primary", scale=2)
|
| 775 |
|
| 776 |
-
# Results section
|
| 777 |
with gr.Row():
|
| 778 |
with gr.Column():
|
| 779 |
ml_output = gr.HTML(label="ML Model Results")
|
|
@@ -783,7 +994,6 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 783 |
with gr.Row():
|
| 784 |
comparison_output = gr.HTML(label="Model Comparison")
|
| 785 |
|
| 786 |
-
# SHAP Feature Importance section
|
| 787 |
gr.Markdown("---")
|
| 788 |
gr.Markdown("### Feature Importance Analysis (SHAP)")
|
| 789 |
|
|
@@ -804,12 +1014,12 @@ with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as d
|
|
| 804 |
**Citation:** Rech MM, Soldera J, Corso LL et al. Development, Internal and Prospective validation of a machine learning model
|
| 805 |
for the prediction of mortality in cirrhotic patients with acute esophageal variceal bleeding. *World J Hepatol* 2025.
|
| 806 |
|
| 807 |
-
**Contact:** mmrech@ucs.br | **Version:**
|
| 808 |
""")
|
| 809 |
|
| 810 |
-
# ===== Custom routes for HTML pages =====
|
| 811 |
import fastapi
|
| 812 |
-
from fastapi.responses import HTMLResponse
|
| 813 |
|
| 814 |
app = fastapi.FastAPI()
|
| 815 |
|
|
@@ -833,6 +1043,31 @@ async def calculator_page():
|
|
| 833 |
return HTMLResponse(content=f.read())
|
| 834 |
return HTMLResponse(content="<h1>File not found</h1>", status_code=404)
|
| 835 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
# Mount Gradio app under the FastAPI app
|
| 837 |
app = gr.mount_gradio_app(app, demo, path="/")
|
| 838 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
EVB Prognosis: 1-Year Mortality Risk Calculator v5.0
|
| 3 |
Calibrated Random Forest with Isotonic Calibration (AUC 0.915)
|
| 4 |
|
| 5 |
Architecture:
|
|
|
|
| 7 |
- JSON isotonic calibration data
|
| 8 |
- Fallback to joblib if ONNX unavailable
|
| 9 |
- SHAP feature importance (global + patient-specific)
|
| 10 |
+
- Partial Dependence Plots (PDP) for numeric features
|
| 11 |
- Glassmorphism dark theme with classic toggle
|
| 12 |
- Landing page with device detection
|
| 13 |
+
- JSON API endpoints for mobile/external consumers
|
| 14 |
"""
|
| 15 |
import os
|
| 16 |
import gradio as gr
|
|
|
|
| 100 |
}
|
| 101 |
CLEAN_NAMES[fn] = mappings.get(clean, clean.replace("_", " ").title())
|
| 102 |
|
| 103 |
+
# Numeric features for PDP
|
| 104 |
+
NUMERIC_FEATURES = {
|
| 105 |
+
"age": {"min": 18, "max": 100, "step": 1, "label": "Age (years)"},
|
| 106 |
+
"albumin": {"min": 1.0, "max": 5.0, "step": 0.1, "label": "Albumin (g/dL)"},
|
| 107 |
+
"total_bilirrubin": {"min": 0.1, "max": 30.0, "step": 0.5, "label": "Total Bilirubin (mg/dL)"},
|
| 108 |
+
"direct_bilirrubina": {"min": 0.1, "max": 10.0, "step": 0.2, "label": "Direct Bilirubin (mg/dL)"},
|
| 109 |
+
"inr": {"min": 0.5, "max": 5.0, "step": 0.1, "label": "INR"},
|
| 110 |
+
"creatinine": {"min": 0.1, "max": 10.0, "step": 0.2, "label": "Creatinine (mg/dL)"},
|
| 111 |
+
"platelets": {"min": 10, "max": 500, "step": 10, "label": "Platelets (x10^3/uL)"},
|
| 112 |
+
"ast": {"min": 10, "max": 500, "step": 10, "label": "AST (U/L)"},
|
| 113 |
+
"alt": {"min": 10, "max": 500, "step": 10, "label": "ALT (U/L)"},
|
| 114 |
+
"hemoglobin": {"min": 5.0, "max": 20.0, "step": 0.5, "label": "Hemoglobin (g/dL)"},
|
| 115 |
+
"hematocrit": {"min": 15, "max": 60, "step": 1, "label": "Hematocrit (%)"},
|
| 116 |
+
"leucocytes": {"min": 1.0, "max": 50.0, "step": 1.0, "label": "Leukocytes (x10^3/uL)"},
|
| 117 |
+
"sodium": {"min": 120, "max": 160, "step": 1, "label": "Sodium (mEq/L)"},
|
| 118 |
+
"potassium": {"min": 2.0, "max": 6.0, "step": 0.1, "label": "Potassium (mEq/L)"},
|
| 119 |
+
"terlipressin_dose": {"min": 0, "max": 20, "step": 1, "label": "Terlipressin Dose (mg)"},
|
| 120 |
+
"time-to-endoscophy_hours": {"min": 0, "max": 48, "step": 1, "label": "Time to Endoscopy (hours)"},
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
# Default patient for PDP baseline
|
| 124 |
+
DEFAULT_PATIENT = {
|
| 125 |
+
"age": 50, "sex": "male", "race": "white", "etiology_cirrosis": "alcohol",
|
| 126 |
+
"hepatorenal_syndrome": "no", "omeprazole": "no", "spironolactone": "yes",
|
| 127 |
+
"furosemide": "yes", "propanolol": "no", "dialisis": "no",
|
| 128 |
+
"portal_vein_thrombosis": "no", "ascitis": "yes", "hepatocellular_carcinoma": "no",
|
| 129 |
+
"albumin": 3.5, "total_bilirrubin": 2.0, "direct_bilirrubina": 0.5,
|
| 130 |
+
"inr": 1.2, "creatinine": 1.0, "platelets": 150, "ast": 35, "alt": 25,
|
| 131 |
+
"hemoglobin": 13, "hematocrit": 40, "leucocytes": 6, "sodium": 140,
|
| 132 |
+
"potassium": 4, "varices": "yes", "red_wale_marks": "no",
|
| 133 |
+
"rupture_point": "no", "active_bleeding": "no", "therapy": "Banding",
|
| 134 |
+
"terlipressin_dose": 2, "time-to-endoscophy_hours": 12, "rebleeding": "no"
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
# Precompute global feature importance at startup
|
| 138 |
GLOBAL_IMPORTANCE = None
|
| 139 |
+
GLOBAL_SHAP_DATA = None # For JSON API
|
| 140 |
if SHAP_AVAILABLE:
|
| 141 |
try:
|
|
|
|
| 142 |
representative_patients = [
|
| 143 |
+
DEFAULT_PATIENT,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
{"age": 75, "sex": "female", "race": "black", "etiology_cirrosis": "hcv",
|
| 145 |
"hepatorenal_syndrome": "yes", "omeprazole": "yes", "spironolactone": "no",
|
| 146 |
"furosemide": "no", "propanolol": "yes", "dialisis": "yes",
|
|
|
|
| 171 |
rf = cc.base_estimator
|
| 172 |
explainer = shap.TreeExplainer(rf)
|
| 173 |
sv = explainer.shap_values(proc)
|
|
|
|
| 174 |
shap_vals = np.array(sv)[0, :, 1]
|
| 175 |
global_shap_accum += np.abs(shap_vals)
|
| 176 |
|
| 177 |
+
n_total = len(representative_patients) * 5
|
| 178 |
GLOBAL_IMPORTANCE = global_shap_accum / n_total
|
| 179 |
|
| 180 |
+
# Build JSON-friendly global SHAP data
|
| 181 |
sorted_idx = np.argsort(GLOBAL_IMPORTANCE)[::-1]
|
| 182 |
+
GLOBAL_SHAP_DATA = []
|
| 183 |
+
for i in range(min(20, len(sorted_idx))):
|
| 184 |
idx = int(sorted_idx[i])
|
| 185 |
+
GLOBAL_SHAP_DATA.append({
|
| 186 |
+
"feature": CLEAN_NAMES[FEATURE_NAMES[idx]],
|
| 187 |
+
"raw_feature": FEATURE_NAMES[idx],
|
| 188 |
+
"importance": round(float(GLOBAL_IMPORTANCE[idx]), 6)
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
print("Global SHAP feature importance (top 10):")
|
| 192 |
+
for item in GLOBAL_SHAP_DATA[:10]:
|
| 193 |
+
print(f" {item['feature']:35s} {item['importance']:.4f}")
|
| 194 |
print("Global SHAP computed successfully")
|
| 195 |
except Exception as e:
|
| 196 |
print(f"Error computing global SHAP: {e}")
|
|
|
|
| 198 |
traceback.print_exc()
|
| 199 |
GLOBAL_IMPORTANCE = None
|
| 200 |
|
| 201 |
+
# ===== PDP Computation =====
|
| 202 |
+
PDP_CACHE = {}
|
| 203 |
+
|
| 204 |
+
def compute_pdp(feature_name, n_points=30):
|
| 205 |
+
"""Compute partial dependence plot data for a numeric feature."""
|
| 206 |
+
if feature_name in PDP_CACHE:
|
| 207 |
+
return PDP_CACHE[feature_name]
|
| 208 |
+
|
| 209 |
+
if feature_name not in NUMERIC_FEATURES:
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
finfo = NUMERIC_FEATURES[feature_name]
|
| 213 |
+
values = np.linspace(finfo["min"], finfo["max"], n_points)
|
| 214 |
+
probabilities = []
|
| 215 |
+
|
| 216 |
+
for val in values:
|
| 217 |
+
patient = DEFAULT_PATIENT.copy()
|
| 218 |
+
patient[feature_name] = float(val)
|
| 219 |
+
df = pd.DataFrame([patient])
|
| 220 |
+
processed = preprocessor.transform(df)
|
| 221 |
+
|
| 222 |
+
if USE_ONNX:
|
| 223 |
+
processed_f32 = processed.astype(np.float32)
|
| 224 |
+
fold_probs = []
|
| 225 |
+
for i, sess in enumerate(onnx_sessions):
|
| 226 |
+
result = sess.run(None, {"X": processed_f32})
|
| 227 |
+
raw_prob = float(result[1][0][1])
|
| 228 |
+
cal = calibration_data[f"fold_{i}"]
|
| 229 |
+
cal_prob = float(np.interp(raw_prob, cal["X_thresholds"], cal["y_thresholds"]))
|
| 230 |
+
fold_probs.append(cal_prob)
|
| 231 |
+
prob = float(np.mean(fold_probs))
|
| 232 |
+
else:
|
| 233 |
+
prob = float(joblib_model.predict_proba(processed)[:, 1][0])
|
| 234 |
+
|
| 235 |
+
probabilities.append(prob)
|
| 236 |
+
|
| 237 |
+
result = {
|
| 238 |
+
"feature": feature_name,
|
| 239 |
+
"label": finfo["label"],
|
| 240 |
+
"values": [round(float(v), 2) for v in values],
|
| 241 |
+
"probabilities": [round(p, 6) for p in probabilities],
|
| 242 |
+
"default_value": DEFAULT_PATIENT.get(feature_name, finfo["min"]),
|
| 243 |
+
"min": finfo["min"],
|
| 244 |
+
"max": finfo["max"]
|
| 245 |
+
}
|
| 246 |
+
PDP_CACHE[feature_name] = result
|
| 247 |
+
return result
|
| 248 |
+
|
| 249 |
+
# Precompute PDP for top features
|
| 250 |
+
print("Precomputing PDP curves...")
|
| 251 |
+
for feat in ["albumin", "inr", "creatinine", "total_bilirrubin", "sodium", "age", "hemoglobin", "platelets"]:
|
| 252 |
+
compute_pdp(feat)
|
| 253 |
+
print(f"PDP precomputed for {len(PDP_CACHE)} features")
|
| 254 |
+
|
| 255 |
def compute_patient_shap(processed):
|
| 256 |
"""Compute patient-specific SHAP values averaged across 5 folds."""
|
| 257 |
if not SHAP_AVAILABLE:
|
|
|
|
| 324 |
|
| 325 |
if val > 0:
|
| 326 |
color = "#ff4b2b"
|
|
|
|
| 327 |
arrow = "+"
|
| 328 |
bar_style = f"width:{abs_pct:.1f}%; margin-left:50%; background:linear-gradient(90deg, rgba(255,75,43,0.3), rgba(255,75,43,0.7));"
|
| 329 |
else:
|
| 330 |
color = "#00e5a0"
|
|
|
|
| 331 |
arrow = ""
|
| 332 |
bar_style = f"width:{abs_pct:.1f}%; margin-left:{50 - abs_pct/2:.1f}%; background:linear-gradient(90deg, rgba(0,229,160,0.7), rgba(0,229,160,0.3));"
|
| 333 |
|
|
|
|
| 341 |
<div style="width:65px; font-size:10px; color:{color}; font-weight:600; font-family:monospace;">{arrow}{val:.4f}</div>
|
| 342 |
</div>"""
|
| 343 |
|
|
|
|
| 344 |
total_shap = float(np.sum(shap_values))
|
| 345 |
direction_text = "above" if total_shap > 0 else "below"
|
| 346 |
direction_color = "#ff4b2b" if total_shap > 0 else "#00e5a0"
|
|
|
|
| 365 |
</div>
|
| 366 |
</div>"""
|
| 367 |
|
| 368 |
+
def render_pdp_html(feature_name):
|
| 369 |
+
"""Render a PDP chart as an SVG inside HTML."""
|
| 370 |
+
pdp_data = compute_pdp(feature_name)
|
| 371 |
+
if pdp_data is None:
|
| 372 |
+
return f"<div style='color:#687076; text-align:center; padding:20px;'>PDP not available for {feature_name}</div>"
|
| 373 |
+
|
| 374 |
+
values = pdp_data["values"]
|
| 375 |
+
probs = pdp_data["probabilities"]
|
| 376 |
+
label = pdp_data["label"]
|
| 377 |
+
default_val = pdp_data["default_value"]
|
| 378 |
+
|
| 379 |
+
# SVG dimensions
|
| 380 |
+
w, h = 600, 300
|
| 381 |
+
pad_l, pad_r, pad_t, pad_b = 60, 20, 30, 50
|
| 382 |
+
plot_w = w - pad_l - pad_r
|
| 383 |
+
plot_h = h - pad_t - pad_b
|
| 384 |
+
|
| 385 |
+
min_v, max_v = min(values), max(values)
|
| 386 |
+
min_p, max_p = min(probs), max(probs)
|
| 387 |
+
# Add some padding to y-axis
|
| 388 |
+
p_range = max_p - min_p
|
| 389 |
+
if p_range < 0.01:
|
| 390 |
+
p_range = 0.1
|
| 391 |
+
min_p = max(0, min_p - 0.05)
|
| 392 |
+
max_p = min(1, max_p + 0.05)
|
| 393 |
+
else:
|
| 394 |
+
min_p = max(0, min_p - p_range * 0.1)
|
| 395 |
+
max_p = min(1, max_p + p_range * 0.1)
|
| 396 |
+
|
| 397 |
+
def x_pos(v):
|
| 398 |
+
return pad_l + (v - min_v) / (max_v - min_v) * plot_w if max_v > min_v else pad_l + plot_w / 2
|
| 399 |
+
|
| 400 |
+
def y_pos(p):
|
| 401 |
+
return pad_t + (1 - (p - min_p) / (max_p - min_p)) * plot_h if max_p > min_p else pad_t + plot_h / 2
|
| 402 |
+
|
| 403 |
+
# Build path
|
| 404 |
+
points = []
|
| 405 |
+
for v, p in zip(values, probs):
|
| 406 |
+
points.append(f"{x_pos(v):.1f},{y_pos(p):.1f}")
|
| 407 |
+
path_d = "M " + " L ".join(points)
|
| 408 |
+
|
| 409 |
+
# Gradient fill area
|
| 410 |
+
fill_points = [f"{x_pos(values[0]):.1f},{y_pos(min_p):.1f}"]
|
| 411 |
+
for v, p in zip(values, probs):
|
| 412 |
+
fill_points.append(f"{x_pos(v):.1f},{y_pos(p):.1f}")
|
| 413 |
+
fill_points.append(f"{x_pos(values[-1]):.1f},{y_pos(min_p):.1f}")
|
| 414 |
+
fill_d = "M " + " L ".join(fill_points) + " Z"
|
| 415 |
+
|
| 416 |
+
# Default value marker
|
| 417 |
+
default_x = x_pos(default_val)
|
| 418 |
+
default_prob = float(np.interp(default_val, values, probs))
|
| 419 |
+
default_y = y_pos(default_prob)
|
| 420 |
+
|
| 421 |
+
# Y-axis ticks
|
| 422 |
+
y_ticks = np.linspace(min_p, max_p, 5)
|
| 423 |
+
y_tick_html = ""
|
| 424 |
+
for yt in y_ticks:
|
| 425 |
+
yp = y_pos(yt)
|
| 426 |
+
y_tick_html += f'<line x1="{pad_l}" y1="{yp:.1f}" x2="{pad_l + plot_w}" y2="{yp:.1f}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>'
|
| 427 |
+
y_tick_html += f'<text x="{pad_l - 8}" y="{yp:.1f}" text-anchor="end" fill="#687076" font-size="10" dominant-baseline="middle">{yt:.1%}</text>'
|
| 428 |
+
|
| 429 |
+
# X-axis ticks
|
| 430 |
+
x_ticks = np.linspace(min_v, max_v, 6)
|
| 431 |
+
x_tick_html = ""
|
| 432 |
+
for xt in x_ticks:
|
| 433 |
+
xp = x_pos(xt)
|
| 434 |
+
x_tick_html += f'<text x="{xp:.1f}" y="{h - 10}" text-anchor="middle" fill="#687076" font-size="10">{xt:.1f}</text>'
|
| 435 |
+
|
| 436 |
+
svg = f"""
|
| 437 |
+
<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg" style="width:100%; max-width:600px;">
|
| 438 |
+
<defs>
|
| 439 |
+
<linearGradient id="pdp-fill-{feature_name}" x1="0" y1="0" x2="0" y2="1">
|
| 440 |
+
<stop offset="0%" stop-color="rgba(0,229,160,0.25)"/>
|
| 441 |
+
<stop offset="100%" stop-color="rgba(0,229,160,0.02)"/>
|
| 442 |
+
</linearGradient>
|
| 443 |
+
<linearGradient id="pdp-line-{feature_name}" x1="0" y1="0" x2="1" y2="0">
|
| 444 |
+
<stop offset="0%" stop-color="#00e5a0"/>
|
| 445 |
+
<stop offset="100%" stop-color="#00b4d8"/>
|
| 446 |
+
</linearGradient>
|
| 447 |
+
</defs>
|
| 448 |
+
|
| 449 |
+
<!-- Grid -->
|
| 450 |
+
{y_tick_html}
|
| 451 |
+
|
| 452 |
+
<!-- Fill area -->
|
| 453 |
+
<path d="{fill_d}" fill="url(#pdp-fill-{feature_name})"/>
|
| 454 |
+
|
| 455 |
+
<!-- Line -->
|
| 456 |
+
<path d="{path_d}" fill="none" stroke="url(#pdp-line-{feature_name})" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
| 457 |
+
|
| 458 |
+
<!-- Default value marker -->
|
| 459 |
+
<line x1="{default_x:.1f}" y1="{pad_t}" x2="{default_x:.1f}" y2="{pad_t + plot_h}" stroke="rgba(255,180,0,0.4)" stroke-width="1" stroke-dasharray="4,4"/>
|
| 460 |
+
<circle cx="{default_x:.1f}" cy="{default_y:.1f}" r="5" fill="#ffb400" stroke="#0a0e17" stroke-width="2"/>
|
| 461 |
+
<text x="{default_x:.1f}" y="{default_y - 12:.1f}" text-anchor="middle" fill="#ffb400" font-size="10" font-weight="600">{default_prob:.1%}</text>
|
| 462 |
+
|
| 463 |
+
<!-- X-axis label -->
|
| 464 |
+
{x_tick_html}
|
| 465 |
+
<text x="{w/2}" y="{h - 2}" text-anchor="middle" fill="#9ba1a6" font-size="11" font-weight="500">{label}</text>
|
| 466 |
+
|
| 467 |
+
<!-- Y-axis label -->
|
| 468 |
+
<text x="12" y="{h/2}" text-anchor="middle" fill="#9ba1a6" font-size="11" font-weight="500" transform="rotate(-90, 12, {h/2})">Mortality Probability</text>
|
| 469 |
+
</svg>
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
return f"""
|
| 473 |
+
<div style="padding:8px 0; text-align:center;">
|
| 474 |
+
<div style="font-size:13px; color:#00e5a0; font-weight:600; margin-bottom:4px;">
|
| 475 |
+
{label} — Partial Dependence
|
| 476 |
+
</div>
|
| 477 |
+
<div style="font-size:10px; color:#687076; margin-bottom:8px;">
|
| 478 |
+
How varying {label.lower()} affects predicted mortality (other features held at defaults)
|
| 479 |
+
</div>
|
| 480 |
+
{svg}
|
| 481 |
+
<div style="display:flex; justify-content:center; gap:16px; margin-top:8px; font-size:10px;">
|
| 482 |
+
<span style="color:#ffb400;">● Default value ({default_val})</span>
|
| 483 |
+
<span style="color:#687076;">Min: {min(probs):.1%} | Max: {max(probs):.1%}</span>
|
| 484 |
+
</div>
|
| 485 |
+
</div>"""
|
| 486 |
+
|
| 487 |
# ===== Clinical Score Calculations =====
|
| 488 |
def calculate_meld(bilirubin, inr, creatinine):
|
| 489 |
bilirubin = max(bilirubin, 1.0)
|
|
|
|
| 516 |
return round(albi, 2), grade
|
| 517 |
|
| 518 |
def apply_isotonic(raw_prob, x_thresholds, y_thresholds):
|
|
|
|
| 519 |
return float(np.interp(raw_prob, x_thresholds, y_thresholds))
|
| 520 |
|
| 521 |
# ===== Prediction Function =====
|
|
|
|
| 552 |
df = pd.DataFrame([input_data])
|
| 553 |
processed = preprocessor.transform(df)
|
| 554 |
|
|
|
|
| 555 |
if USE_ONNX:
|
| 556 |
processed_f32 = processed.astype(np.float32)
|
| 557 |
fold_probs = []
|
|
|
|
| 569 |
probability = joblib_model.predict_proba(processed)[:, 1][0]
|
| 570 |
inference_engine = "joblib"
|
| 571 |
|
|
|
|
| 572 |
patient_shap, base_value = compute_patient_shap(processed)
|
| 573 |
|
|
|
|
| 574 |
confidence_margin = 0.15
|
| 575 |
ci_lower = max(0, probability - confidence_margin)
|
| 576 |
ci_upper = min(1, probability + confidence_margin)
|
| 577 |
|
|
|
|
| 578 |
meld = calculate_meld(total_bilirrubin, inr, creatinine)
|
| 579 |
meld_na = calculate_meld_na(total_bilirrubin, inr, creatinine, sodium)
|
| 580 |
child_pugh, cp_class = calculate_child_pugh(total_bilirrubin, albumin, inr, ascitis)
|
| 581 |
albi, albi_grade = calculate_albi(total_bilirrubin, albumin)
|
| 582 |
|
|
|
|
| 583 |
if probability < 0.3:
|
| 584 |
risk_category, risk_icon, risk_bar_color = "LOW RISK", "OK", "#00e5a0"
|
| 585 |
elif probability < 0.6:
|
|
|
|
| 590 |
pct = probability * 100
|
| 591 |
bar_width = int(pct)
|
| 592 |
|
|
|
|
| 593 |
ml_output = f"""
|
| 594 |
<div style="text-align:center; padding:10px 0;">
|
| 595 |
<div style="font-size:48px; font-weight:800; background: linear-gradient(135deg, {risk_bar_color}, #fff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; letter-spacing:2px;">{pct:.1f}%</div>
|
|
|
|
| 604 |
</div>
|
| 605 |
"""
|
| 606 |
|
|
|
|
| 607 |
traditional_scores = f"""
|
| 608 |
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; padding:8px 0;">
|
| 609 |
<div style="background:rgba(0,229,160,0.08); border:1px solid rgba(0,229,160,0.2); border-radius:12px; padding:16px; text-align:center;">
|
|
|
|
| 629 |
</div>
|
| 630 |
"""
|
| 631 |
|
|
|
|
| 632 |
comparison = f"""
|
| 633 |
<div style="padding:8px 0;">
|
| 634 |
<table style="width:100%; border-collapse:separate; border-spacing:0 6px;">
|
|
|
|
| 672 |
</div>
|
| 673 |
"""
|
| 674 |
|
|
|
|
| 675 |
global_shap_html = render_global_shap_html()
|
| 676 |
patient_shap_html = render_patient_shap_html(patient_shap, base_value, probability)
|
| 677 |
|
| 678 |
return ml_output, traditional_scores, comparison, global_shap_html, patient_shap_html
|
| 679 |
|
| 680 |
+
# ===== PDP Gradio Function =====
|
| 681 |
+
def generate_pdp_plot(feature_name):
|
| 682 |
+
"""Generate PDP HTML for the selected feature."""
|
| 683 |
+
if not feature_name or feature_name not in NUMERIC_FEATURES:
|
| 684 |
+
return "<div style='color:#687076; text-align:center; padding:40px;'>Select a feature to view its partial dependence plot</div>"
|
| 685 |
+
return render_pdp_html(feature_name)
|
| 686 |
+
|
| 687 |
# ===== CSS Themes =====
|
| 688 |
GLASSMORPHISM_CSS = """
|
|
|
|
| 689 |
.gradio-container {
|
| 690 |
background: linear-gradient(135deg, #0a0e17 0%, #0d1520 30%, #0a1628 60%, #0f0a1a 100%) !important;
|
| 691 |
min-height: 100vh;
|
| 692 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
| 693 |
}
|
|
|
|
| 694 |
.gradio-container::before {
|
| 695 |
content: '';
|
| 696 |
position: fixed;
|
|
|
|
| 710 |
75% { transform: translate(1%, -2%) rotate(0.5deg); }
|
| 711 |
}
|
| 712 |
.gradio-container > * { position: relative; z-index: 1; }
|
|
|
|
| 713 |
.block, .form, .panel {
|
| 714 |
background: rgba(15, 23, 42, 0.6) !important;
|
| 715 |
backdrop-filter: blur(20px) !important;
|
|
|
|
| 718 |
border-radius: 16px !important;
|
| 719 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
|
| 720 |
}
|
|
|
|
| 721 |
.tab-nav {
|
| 722 |
background: rgba(15, 23, 42, 0.4) !important;
|
| 723 |
border-radius: 12px !important; padding: 4px !important;
|
|
|
|
| 734 |
color: #00e5a0 !important; font-weight: 600 !important;
|
| 735 |
box-shadow: 0 0 20px rgba(0, 229, 160, 0.1) !important;
|
| 736 |
}
|
|
|
|
| 737 |
input, select, textarea, .wrap {
|
| 738 |
background: rgba(15, 23, 42, 0.8) !important;
|
| 739 |
border: 1px solid rgba(0, 229, 160, 0.15) !important;
|
|
|
|
| 746 |
outline: none !important;
|
| 747 |
}
|
| 748 |
label, .label-wrap span { color: #9ba1a6 !important; font-weight: 500 !important; font-size: 13px !important; }
|
|
|
|
| 749 |
input[type="range"] { background: transparent !important; border: none !important; }
|
| 750 |
input[type="range"]::-webkit-slider-track { background: rgba(0, 229, 160, 0.15) !important; height: 4px !important; border-radius: 2px !important; }
|
| 751 |
input[type="range"]::-webkit-slider-thumb { background: #00e5a0 !important; border: 2px solid rgba(0, 229, 160, 0.5) !important; box-shadow: 0 0 10px rgba(0, 229, 160, 0.3) !important; }
|
| 752 |
.range_input input[type="number"], input[type="number"] { background: rgba(15, 23, 42, 0.8) !important; color: #00e5a0 !important; font-weight: 600 !important; border: 1px solid rgba(0, 229, 160, 0.2) !important; }
|
|
|
|
| 753 |
.primary {
|
| 754 |
background: linear-gradient(135deg, #00e5a0 0%, #00b4d8 100%) !important;
|
| 755 |
border: none !important; color: #0a0e17 !important;
|
|
|
|
| 760 |
transition: all 0.3s ease !important;
|
| 761 |
}
|
| 762 |
.primary:hover { box-shadow: 0 6px 30px rgba(0, 229, 160, 0.5) !important; transform: translateY(-1px) !important; }
|
|
|
|
| 763 |
.markdown-text, .prose, .md { color: #ecedee !important; }
|
| 764 |
.markdown-text h1, .prose h1 { background: linear-gradient(135deg, #00e5a0, #00b4d8) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; font-weight: 800 !important; letter-spacing: 1px !important; }
|
| 765 |
.markdown-text h3, .prose h3 { color: #00e5a0 !important; font-weight: 600 !important; }
|
| 766 |
.markdown-text p, .prose p { color: #9ba1a6 !important; line-height: 1.7 !important; }
|
| 767 |
.markdown-text strong, .prose strong { color: #ecedee !important; }
|
|
|
|
| 768 |
.wrap .secondary-wrap { background: rgba(15, 23, 42, 0.8) !important; border: 1px solid rgba(0, 229, 160, 0.15) !important; }
|
| 769 |
ul.options li { color: #ecedee !important; background: rgba(15, 23, 42, 0.95) !important; }
|
| 770 |
ul.options li:hover, ul.options li.selected { background: rgba(0, 229, 160, 0.15) !important; color: #00e5a0 !important; }
|
|
|
|
| 771 |
::-webkit-scrollbar { width: 6px; }
|
| 772 |
::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.3); }
|
| 773 |
::-webkit-scrollbar-thumb { background: rgba(0, 229, 160, 0.2); border-radius: 3px; }
|
| 774 |
::-webkit-scrollbar-thumb:hover { background: rgba(0, 229, 160, 0.4); }
|
|
|
|
| 775 |
footer { display: none !important; }
|
| 776 |
.gap { gap: 12px !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
"""
|
| 778 |
|
| 779 |
CLASSIC_OVERRIDE_CSS = """
|
|
|
|
| 780 |
.classic-mode .gradio-container { background: #f7f8fa !important; }
|
| 781 |
.classic-mode .gradio-container::before { display: none !important; }
|
| 782 |
.classic-mode .block, .classic-mode .form, .classic-mode .panel {
|
|
|
|
| 831 |
|
| 832 |
with gr.Blocks(css=FULL_CSS, title="EVB Prognosis Calculator", js=THEME_JS) as demo:
|
| 833 |
|
|
|
|
| 834 |
gr.HTML("""
|
| 835 |
<div id="theme-controls" style="position:fixed; top:12px; right:12px; z-index:9999; display:flex; gap:8px; align-items:center;">
|
| 836 |
<button id="theme-toggle" onclick="toggleTheme()" style="
|
|
|
|
| 846 |
background:rgba(0,180,216,0.15); color:#00b4d8;
|
| 847 |
border:1px solid rgba(0,180,216,0.3);
|
| 848 |
text-decoration:none;
|
|
|
|
| 849 |
">Mobile Version</a>
|
| 850 |
</div>
|
| 851 |
""")
|
|
|
|
| 915 |
sodium = gr.Slider(minimum=120, maximum=160, step=1, label="Sodium (mEq/L)", value=140)
|
| 916 |
potassium = gr.Slider(minimum=2, maximum=6, step=0.1, label="Potassium (mEq/L)", value=4)
|
| 917 |
|
| 918 |
+
# PDP Tab
|
| 919 |
+
with gr.Tab("4. Partial Dependence"):
|
| 920 |
+
gr.Markdown("""
|
| 921 |
+
### Partial Dependence Plots (PDP)
|
| 922 |
+
|
| 923 |
+
Explore how varying a single feature affects the predicted mortality probability, while holding all other features at their default values. Select a feature below to visualize its marginal effect on the model output.
|
| 924 |
+
""")
|
| 925 |
+
|
| 926 |
+
feature_choices = [(v["label"], k) for k, v in NUMERIC_FEATURES.items()]
|
| 927 |
+
pdp_feature = gr.Dropdown(
|
| 928 |
+
choices=feature_choices,
|
| 929 |
+
label="Select Feature",
|
| 930 |
+
value="albumin"
|
| 931 |
+
)
|
| 932 |
+
pdp_output = gr.HTML(value=render_pdp_html("albumin"))
|
| 933 |
+
|
| 934 |
+
pdp_feature.change(
|
| 935 |
+
fn=generate_pdp_plot,
|
| 936 |
+
inputs=[pdp_feature],
|
| 937 |
+
outputs=[pdp_output]
|
| 938 |
+
)
|
| 939 |
+
|
| 940 |
+
gr.Markdown("""
|
| 941 |
+
*PDP curves show the average predicted probability when the selected feature is varied across its range. The orange dot marks the default patient value. These plots help clinicians understand which features have the strongest marginal effects on predicted mortality.*
|
| 942 |
+
""")
|
| 943 |
+
|
| 944 |
with gr.Tab("About"):
|
| 945 |
gr.Markdown("""
|
| 946 |
### Model Architecture & Validation
|
|
|
|
| 957 |
- Prospective validation (n=24): AUC 0.927 (SD +/- 0.053)
|
| 958 |
- Superior performance vs. traditional scores (p < 0.001)
|
| 959 |
|
| 960 |
+
### Interpretability Features
|
| 961 |
+
|
| 962 |
+
- **Global SHAP**: Mean absolute SHAP values across representative patients
|
| 963 |
+
- **Patient-Specific SHAP**: Per-prediction feature contributions (waterfall chart)
|
| 964 |
+
- **Partial Dependence Plots**: Marginal effect of each numeric feature on mortality
|
| 965 |
+
|
| 966 |
### Limitations
|
| 967 |
- Single-center development (external validation ongoing)
|
| 968 |
- Small prospective validation cohort (n=24)
|
|
|
|
| 985 |
with gr.Row():
|
| 986 |
predict_btn = gr.Button("CALCULATE RISK ASSESSMENT", variant="primary", scale=2)
|
| 987 |
|
|
|
|
| 988 |
with gr.Row():
|
| 989 |
with gr.Column():
|
| 990 |
ml_output = gr.HTML(label="ML Model Results")
|
|
|
|
| 994 |
with gr.Row():
|
| 995 |
comparison_output = gr.HTML(label="Model Comparison")
|
| 996 |
|
|
|
|
| 997 |
gr.Markdown("---")
|
| 998 |
gr.Markdown("### Feature Importance Analysis (SHAP)")
|
| 999 |
|
|
|
|
| 1014 |
**Citation:** Rech MM, Soldera J, Corso LL et al. Development, Internal and Prospective validation of a machine learning model
|
| 1015 |
for the prediction of mortality in cirrhotic patients with acute esophageal variceal bleeding. *World J Hepatol* 2025.
|
| 1016 |
|
| 1017 |
+
**Contact:** mmrech@ucs.br | **Version:** 5.0 (ONNX + SHAP + PDP + Glassmorphism)
|
| 1018 |
""")
|
| 1019 |
|
| 1020 |
+
# ===== Custom routes for HTML pages + JSON APIs =====
|
| 1021 |
import fastapi
|
| 1022 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 1023 |
|
| 1024 |
app = fastapi.FastAPI()
|
| 1025 |
|
|
|
|
| 1043 |
return HTMLResponse(content=f.read())
|
| 1044 |
return HTMLResponse(content="<h1>File not found</h1>", status_code=404)
|
| 1045 |
|
| 1046 |
+
# JSON API endpoints for mobile/external consumers
|
| 1047 |
+
@app.get("/api/global-shap")
|
| 1048 |
+
async def api_global_shap():
|
| 1049 |
+
"""Return global SHAP feature importance as JSON."""
|
| 1050 |
+
if GLOBAL_SHAP_DATA is None:
|
| 1051 |
+
return JSONResponse(content={"error": "Global SHAP not available"}, status_code=503)
|
| 1052 |
+
return JSONResponse(content={"features": GLOBAL_SHAP_DATA})
|
| 1053 |
+
|
| 1054 |
+
@app.get("/api/pdp/{feature_name}")
|
| 1055 |
+
async def api_pdp(feature_name: str):
|
| 1056 |
+
"""Return PDP data for a specific numeric feature."""
|
| 1057 |
+
if feature_name not in NUMERIC_FEATURES:
|
| 1058 |
+
return JSONResponse(
|
| 1059 |
+
content={"error": f"Unknown feature: {feature_name}", "available": list(NUMERIC_FEATURES.keys())},
|
| 1060 |
+
status_code=400
|
| 1061 |
+
)
|
| 1062 |
+
data = compute_pdp(feature_name)
|
| 1063 |
+
return JSONResponse(content=data)
|
| 1064 |
+
|
| 1065 |
+
@app.get("/api/pdp")
|
| 1066 |
+
async def api_pdp_list():
|
| 1067 |
+
"""List available features for PDP."""
|
| 1068 |
+
features = [{"name": k, "label": v["label"], "min": v["min"], "max": v["max"]} for k, v in NUMERIC_FEATURES.items()]
|
| 1069 |
+
return JSONResponse(content={"features": features})
|
| 1070 |
+
|
| 1071 |
# Mount Gradio app under the FastAPI app
|
| 1072 |
app = gr.mount_gradio_app(app, demo, path="/")
|
| 1073 |
|
evb_prognosis_mobile.html
CHANGED
|
@@ -484,6 +484,85 @@
|
|
| 484 |
color: var(--accent);
|
| 485 |
text-decoration: none;
|
| 486 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
</style>
|
| 488 |
</head>
|
| 489 |
<body>
|
|
@@ -667,12 +746,32 @@
|
|
| 667 |
</div>
|
| 668 |
|
| 669 |
<button class="btn-export" id="btn-export" onclick="exportResults()">💾 Export JSON</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
</div>
|
| 671 |
</div>
|
| 672 |
|
| 673 |
<div class="footer">
|
| 674 |
<p><strong>Citation:</strong> Rech MM, Soldera J, Corso LL et al. <em>World J Hepatol</em>. 2025.</p>
|
| 675 |
-
<p style="margin-top:6px;"><a href="mailto:mmrech@ucs.br">mmrech@ucs.br</a> |
|
| 676 |
</div>
|
| 677 |
|
| 678 |
<script>
|
|
@@ -756,22 +855,75 @@
|
|
| 756 |
throw new Error('No data from API');
|
| 757 |
}
|
| 758 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
function parseMLOutput(md) {
|
| 760 |
const r = { probability: null, ciLower: null, ciUpper: null, prediction: null, riskCategory: null };
|
| 761 |
-
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
r.prediction = md.includes('Death within 1 year') ? 'Death within 1 year' : 'Survival beyond 1 year';
|
| 764 |
-
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
return r;
|
| 767 |
}
|
| 768 |
|
| 769 |
function parseTraditionalScores(md) {
|
| 770 |
const r = { meld: null, meldNa: null, childPugh: null, cpClass: null };
|
|
|
|
| 771 |
const m1 = md.match(/MELD Score:\*\*\s*(\d+)/); if (m1) r.meld = parseInt(m1[1]);
|
| 772 |
const m2 = md.match(/MELD-Na Score:\*\*\s*(\d+)/); if (m2) r.meldNa = parseInt(m2[1]);
|
| 773 |
const m3 = md.match(/Child-Pugh Score:\*\*\s*(\d+)\s*\(Class\s*([ABC])\)/);
|
| 774 |
if (m3) { r.childPugh = parseInt(m3[1]); r.cpClass = m3[2]; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
return r;
|
| 776 |
}
|
| 777 |
|
|
@@ -786,7 +938,12 @@
|
|
| 786 |
$('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 787 |
|
| 788 |
try {
|
| 789 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
const ml = parseMLOutput(mlOut);
|
| 791 |
const trad = parseTraditionalScores(tradOut);
|
| 792 |
|
|
@@ -843,6 +1000,10 @@
|
|
| 843 |
|
| 844 |
$('comparison-section').style.display = 'block';
|
| 845 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
} catch (err) {
|
| 847 |
console.error(err);
|
| 848 |
errBanner.textContent = 'API Error: ' + err.message + '. Showing local scores only.';
|
|
|
|
| 484 |
color: var(--accent);
|
| 485 |
text-decoration: none;
|
| 486 |
}
|
| 487 |
+
|
| 488 |
+
/* SHAP Feature Importance */
|
| 489 |
+
.shap-section {
|
| 490 |
+
margin: 16px 0;
|
| 491 |
+
padding: 16px 0;
|
| 492 |
+
border-top: 1px solid var(--glass-border);
|
| 493 |
+
}
|
| 494 |
+
.shap-section h3 {
|
| 495 |
+
font-size: 0.7rem;
|
| 496 |
+
color: var(--accent);
|
| 497 |
+
text-transform: uppercase;
|
| 498 |
+
letter-spacing: 1px;
|
| 499 |
+
margin-bottom: 4px;
|
| 500 |
+
}
|
| 501 |
+
.shap-subtitle {
|
| 502 |
+
font-size: 0.6rem;
|
| 503 |
+
color: var(--text-dim);
|
| 504 |
+
margin-bottom: 12px;
|
| 505 |
+
}
|
| 506 |
+
.shap-bar-row {
|
| 507 |
+
display: flex;
|
| 508 |
+
align-items: center;
|
| 509 |
+
gap: 6px;
|
| 510 |
+
margin: 3px 0;
|
| 511 |
+
}
|
| 512 |
+
.shap-bar-label {
|
| 513 |
+
width: 90px;
|
| 514 |
+
text-align: right;
|
| 515 |
+
font-size: 0.55rem;
|
| 516 |
+
color: var(--text-dim);
|
| 517 |
+
white-space: nowrap;
|
| 518 |
+
overflow: hidden;
|
| 519 |
+
text-overflow: ellipsis;
|
| 520 |
+
flex-shrink: 0;
|
| 521 |
+
}
|
| 522 |
+
.shap-bar-track {
|
| 523 |
+
flex: 1;
|
| 524 |
+
height: 14px;
|
| 525 |
+
background: rgba(255,255,255,0.04);
|
| 526 |
+
border-radius: 3px;
|
| 527 |
+
overflow: hidden;
|
| 528 |
+
position: relative;
|
| 529 |
+
}
|
| 530 |
+
.shap-bar-fill {
|
| 531 |
+
height: 100%;
|
| 532 |
+
border-radius: 3px;
|
| 533 |
+
transition: width 0.5s;
|
| 534 |
+
}
|
| 535 |
+
.shap-bar-value {
|
| 536 |
+
width: 50px;
|
| 537 |
+
font-size: 0.52rem;
|
| 538 |
+
font-family: 'JetBrains Mono', monospace;
|
| 539 |
+
font-weight: 600;
|
| 540 |
+
flex-shrink: 0;
|
| 541 |
+
}
|
| 542 |
+
.shap-legend {
|
| 543 |
+
display: flex;
|
| 544 |
+
justify-content: center;
|
| 545 |
+
gap: 12px;
|
| 546 |
+
font-size: 0.55rem;
|
| 547 |
+
margin-bottom: 8px;
|
| 548 |
+
}
|
| 549 |
+
.shap-summary {
|
| 550 |
+
margin-top: 10px;
|
| 551 |
+
padding: 8px;
|
| 552 |
+
background: rgba(255,255,255,0.03);
|
| 553 |
+
border-radius: 8px;
|
| 554 |
+
text-align: center;
|
| 555 |
+
font-size: 0.6rem;
|
| 556 |
+
color: var(--text-dim);
|
| 557 |
+
}
|
| 558 |
+
.shap-center-line {
|
| 559 |
+
position: absolute;
|
| 560 |
+
left: 50%;
|
| 561 |
+
top: 0;
|
| 562 |
+
bottom: 0;
|
| 563 |
+
width: 1px;
|
| 564 |
+
background: rgba(255,255,255,0.12);
|
| 565 |
+
}
|
| 566 |
</style>
|
| 567 |
</head>
|
| 568 |
<body>
|
|
|
|
| 746 |
</div>
|
| 747 |
|
| 748 |
<button class="btn-export" id="btn-export" onclick="exportResults()">💾 Export JSON</button>
|
| 749 |
+
|
| 750 |
+
<!-- SHAP Global Feature Importance -->
|
| 751 |
+
<div class="shap-section" id="shap-global-section" style="display:none;">
|
| 752 |
+
<h3>Global Feature Importance</h3>
|
| 753 |
+
<div class="shap-subtitle">Mean |SHAP| across representative patients (prospective-validated model)</div>
|
| 754 |
+
<div id="shap-global-bars"></div>
|
| 755 |
+
</div>
|
| 756 |
+
|
| 757 |
+
<!-- SHAP Patient-Specific -->
|
| 758 |
+
<div class="shap-section" id="shap-patient-section" style="display:none;">
|
| 759 |
+
<h3>This Patient's Feature Contributions</h3>
|
| 760 |
+
<div class="shap-subtitle" id="shap-patient-subtitle">How each feature shifts risk from the population baseline</div>
|
| 761 |
+
<div class="shap-legend">
|
| 762 |
+
<span style="color:var(--danger);">Red = increases risk →</span>
|
| 763 |
+
<span style="color:rgba(255,255,255,0.2);">|</span>
|
| 764 |
+
<span style="color:var(--accent2);">← Green = decreases risk</span>
|
| 765 |
+
</div>
|
| 766 |
+
<div id="shap-patient-bars"></div>
|
| 767 |
+
<div class="shap-summary" id="shap-patient-summary"></div>
|
| 768 |
+
</div>
|
| 769 |
</div>
|
| 770 |
</div>
|
| 771 |
|
| 772 |
<div class="footer">
|
| 773 |
<p><strong>Citation:</strong> Rech MM, Soldera J, Corso LL et al. <em>World J Hepatol</em>. 2025.</p>
|
| 774 |
+
<p style="margin-top:6px;"><a href="mailto:mmrech@ucs.br">mmrech@ucs.br</a> | v5.0 Mobile (SHAP)</p>
|
| 775 |
</div>
|
| 776 |
|
| 777 |
<script>
|
|
|
|
| 855 |
throw new Error('No data from API');
|
| 856 |
}
|
| 857 |
|
| 858 |
+
// SHAP rendering functions
|
| 859 |
+
function renderGlobalShapBars(htmlContent) {
|
| 860 |
+
const container = $('shap-global-bars');
|
| 861 |
+
if (!htmlContent || htmlContent.includes('not available')) {
|
| 862 |
+
$('shap-global-section').style.display = 'none';
|
| 863 |
+
return;
|
| 864 |
+
}
|
| 865 |
+
// The API returns pre-rendered HTML from the Gradio backend
|
| 866 |
+
container.innerHTML = htmlContent;
|
| 867 |
+
$('shap-global-section').style.display = 'block';
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
function renderPatientShapBars(htmlContent) {
|
| 871 |
+
const container = $('shap-patient-bars');
|
| 872 |
+
if (!htmlContent || htmlContent.includes('not available')) {
|
| 873 |
+
$('shap-patient-section').style.display = 'none';
|
| 874 |
+
return;
|
| 875 |
+
}
|
| 876 |
+
// The API returns pre-rendered HTML from the Gradio backend
|
| 877 |
+
container.innerHTML = htmlContent;
|
| 878 |
+
$('shap-patient-section').style.display = 'block';
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
function parseMLOutput(md) {
|
| 882 |
const r = { probability: null, ciLower: null, ciUpper: null, prediction: null, riskCategory: null };
|
| 883 |
+
// Try HTML format first (v5 returns styled HTML)
|
| 884 |
+
const htmlPct = md.match(/>([\d.]+)%<\/div>\s*<div[^>]*>1-Year Mortality/);
|
| 885 |
+
if (htmlPct) {
|
| 886 |
+
r.probability = parseFloat(htmlPct[1]) / 100;
|
| 887 |
+
}
|
| 888 |
+
// Try markdown format as fallback
|
| 889 |
+
if (r.probability === null) {
|
| 890 |
+
const pm = md.match(/Mortality Probability:\*\*\s*([\d.]+)%\s*\(95% CI:\s*([\d.]+)%\s*-\s*([\d.]+)%\)/);
|
| 891 |
+
if (pm) { r.probability = parseFloat(pm[1])/100; r.ciLower = parseFloat(pm[2])/100; r.ciUpper = parseFloat(pm[3])/100; }
|
| 892 |
+
}
|
| 893 |
+
// Parse CI from HTML format
|
| 894 |
+
const ciMatch = md.match(/95% CI:\s*([\d.]+)%\s*-\s*([\d.]+)%/);
|
| 895 |
+
if (ciMatch) { r.ciLower = parseFloat(ciMatch[1])/100; r.ciUpper = parseFloat(ciMatch[2])/100; }
|
| 896 |
r.prediction = md.includes('Death within 1 year') ? 'Death within 1 year' : 'Survival beyond 1 year';
|
| 897 |
+
// Try both HTML and markdown risk category formats
|
| 898 |
+
const rm = md.match(/(LOW RISK|MODERATE RISK|HIGH RISK|Low Risk|Moderate Risk|High Risk)/i);
|
| 899 |
+
if (rm) {
|
| 900 |
+
const cat = rm[1].toLowerCase();
|
| 901 |
+
if (cat.includes('low')) r.riskCategory = 'Low Risk';
|
| 902 |
+
else if (cat.includes('moderate')) r.riskCategory = 'Moderate Risk';
|
| 903 |
+
else r.riskCategory = 'High Risk';
|
| 904 |
+
}
|
| 905 |
return r;
|
| 906 |
}
|
| 907 |
|
| 908 |
function parseTraditionalScores(md) {
|
| 909 |
const r = { meld: null, meldNa: null, childPugh: null, cpClass: null };
|
| 910 |
+
// Try markdown format
|
| 911 |
const m1 = md.match(/MELD Score:\*\*\s*(\d+)/); if (m1) r.meld = parseInt(m1[1]);
|
| 912 |
const m2 = md.match(/MELD-Na Score:\*\*\s*(\d+)/); if (m2) r.meldNa = parseInt(m2[1]);
|
| 913 |
const m3 = md.match(/Child-Pugh Score:\*\*\s*(\d+)\s*\(Class\s*([ABC])\)/);
|
| 914 |
if (m3) { r.childPugh = parseInt(m3[1]); r.cpClass = m3[2]; }
|
| 915 |
+
// Try HTML format (v5 returns styled HTML cards)
|
| 916 |
+
if (r.meld === null) {
|
| 917 |
+
// Look for pattern: >NUMBER</div>...MELD Score
|
| 918 |
+
const hm1 = md.match(/>\s*(\d+)\s*<\/div>[^<]*<div[^>]*>MELD Score/); if (hm1) r.meld = parseInt(hm1[1]);
|
| 919 |
+
}
|
| 920 |
+
if (r.meldNa === null) {
|
| 921 |
+
const hm2 = md.match(/>\s*(\d+)\s*<\/div>[^<]*<div[^>]*>MELD-Na Score/); if (hm2) r.meldNa = parseInt(hm2[1]);
|
| 922 |
+
}
|
| 923 |
+
if (r.childPugh === null) {
|
| 924 |
+
const hm3 = md.match(/>\s*(\d+)\s*<\/div>[^<]*<div[^>]*>Child-Pugh \(Class ([ABC])\)/);
|
| 925 |
+
if (hm3) { r.childPugh = parseInt(hm3[1]); r.cpClass = hm3[2]; }
|
| 926 |
+
}
|
| 927 |
return r;
|
| 928 |
}
|
| 929 |
|
|
|
|
| 938 |
$('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 939 |
|
| 940 |
try {
|
| 941 |
+
const apiResult = await callGradioAPI();
|
| 942 |
+
// API returns 5 outputs: [ml_html, traditional_html, comparison_html, global_shap_html, patient_shap_html]
|
| 943 |
+
const mlOut = apiResult[0];
|
| 944 |
+
const tradOut = apiResult[1];
|
| 945 |
+
const globalShapHtml = apiResult.length > 3 ? apiResult[3] : null;
|
| 946 |
+
const patientShapHtml = apiResult.length > 4 ? apiResult[4] : null;
|
| 947 |
const ml = parseMLOutput(mlOut);
|
| 948 |
const trad = parseTraditionalScores(tradOut);
|
| 949 |
|
|
|
|
| 1000 |
|
| 1001 |
$('comparison-section').style.display = 'block';
|
| 1002 |
|
| 1003 |
+
// Render SHAP visualizations
|
| 1004 |
+
if (globalShapHtml) renderGlobalShapBars(globalShapHtml);
|
| 1005 |
+
if (patientShapHtml) renderPatientShapBars(patientShapHtml);
|
| 1006 |
+
|
| 1007 |
} catch (err) {
|
| 1008 |
console.error(err);
|
| 1009 |
errBanner.textContent = 'API Error: ' + err.message + '. Showing local scores only.';
|