mmrech commited on
Commit
a36bd47
·
1 Parent(s): d6b3520

v5.0: Add PDP tab, JSON API endpoints, SHAP in mobile HTML

Browse files
Files changed (2) hide show
  1. app.py +292 -57
  2. 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
- {"age": 50, "sex": "male", "race": "white", "etiology_cirrosis": "alcohol",
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 # patients * folds
152
  GLOBAL_IMPORTANCE = global_shap_accum / n_total
153
 
154
- # Sort and get top 20
155
  sorted_idx = np.argsort(GLOBAL_IMPORTANCE)[::-1]
156
- print("Global SHAP feature importance (top 10):")
157
- for i in range(min(10, len(sorted_idx))):
158
  idx = int(sorted_idx[i])
159
- print(f" {CLEAN_NAMES[FEATURE_NAMES[idx]]:35s} {float(GLOBAL_IMPORTANCE[idx]):.4f}")
 
 
 
 
 
 
 
 
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:** 4.0 (ONNX + SHAP + Glassmorphism)
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} &mdash; 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;">&#9679; 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()">&#128190; 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> | v3.0 Mobile</p>
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
- const pm = md.match(/Mortality Probability:\*\*\s*([\d.]+)%\s*\(95% CI:\s*([\d.]+)%\s*-\s*([\d.]+)%\)/);
762
- if (pm) { r.probability = parseFloat(pm[1])/100; r.ciLower = parseFloat(pm[2])/100; r.ciUpper = parseFloat(pm[3])/100; }
 
 
 
 
 
 
 
 
 
 
 
763
  r.prediction = md.includes('Death within 1 year') ? 'Death within 1 year' : 'Survival beyond 1 year';
764
- const rm = md.match(/(Low Risk|Moderate Risk|High Risk)/);
765
- if (rm) r.riskCategory = rm[1];
 
 
 
 
 
 
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 [mlOut, tradOut] = await callGradioAPI();
 
 
 
 
 
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()">&#128190; 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 &rarr;</span>
763
+ <span style="color:rgba(255,255,255,0.2);">|</span>
764
+ <span style="color:var(--accent2);">&larr; 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.';