Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import numpy as np | |
| import pandas as pd | |
| import pickle | |
| MODEL_PATH = "HantaVarNet.pkl" | |
| def load_model(): | |
| with open("HantaVarNet.pkl", "rb") as f: | |
| package = pickle.load(f) | |
| return package["model"] if isinstance(package, dict) else package | |
| # Clinical knowledge: display labels, units, normal ranges | |
| FEATURE_INFO = { | |
| "Creatinine_mg/dL": ("Kreatinin", "mg/dL", 0.6, 1.2, "high"), | |
| "ALT_U/L": ("ALT (SGPT)", "U/L", 7.0, 56.0, "high"), | |
| "AST_U/L": ("AST (SGOT)", "U/L", 10.0, 40.0, "high"), | |
| "BUN_mg/dL": ("BUN", "mg/dL", 7.0, 25.0, "high"), | |
| "Platelet_Count_K/uL": ("Trombosit", "K/µL", 150, 400, "low"), | |
| } | |
| # Exact thresholds | |
| TREE_THRESHOLDS = { | |
| "Creatinine_mg/dL": 1.35, | |
| "ALT_U/L": 65.95, | |
| "AST_U/L": 78.00, | |
| "BUN_mg/dL": 39.05, | |
| "Platelet_Count_K/uL": 146.14, | |
| } | |
| # Recommendation catalogue per feature | |
| RECOMMENDATIONS = { | |
| "Creatinine_mg/dL": { | |
| "icon": "🫘", | |
| "title": "Evaluasi Fungsi Ginjal", | |
| "color": "orange", | |
| "items": [ | |
| "Perbanyak konsumsi air putih (min. 2 liter/hari) untuk mendukung ekskresi kreatinin.", | |
| "Hindari penggunaan obat nefrotoksik (NSAID, aminoglikosida) tanpa pengawasan dokter.", | |
| "Lakukan pemeriksaan urinalisis, GFR, dan USG ginjal untuk evaluasi menyeluruh.", | |
| "Pertimbangkan konsultasi dokter spesialis nefrologi.", | |
| ], | |
| }, | |
| "ALT_U/L": { | |
| "icon": "🫀", | |
| "title": "Jaga Kesehatan Hati", | |
| "color": "red", | |
| "items": [ | |
| "Hindari konsumsi alkohol dan makanan tinggi lemak jenuh / makanan ultraproses.", | |
| "Batasi obat-obatan hepatotoksik (parasetamol dosis tinggi, statin) tanpa resep.", | |
| "Konsumsi sayuran hijau, buah-buahan, dan protein rendah lemak.", | |
| "Lakukan pemeriksaan USG abdomen untuk menilai kondisi hati.", | |
| ], | |
| }, | |
| "AST_U/L": { | |
| "icon": "🔬", | |
| "title": "Pemeriksaan Lanjutan Hati", | |
| "color": "red", | |
| "items": [ | |
| "Lakukan panel fungsi hati lengkap (LFT): ALT, AST, GGT, bilirubin, albumin.", | |
| "Istirahat cukup dan hindari aktivitas fisik berat sementara waktu.", | |
| "Monitor gejala seperti ikterus (kuning), mual, atau nyeri perut kanan atas.", | |
| "Konsultasikan dengan dokter spesialis gastrohepatologi.", | |
| ], | |
| }, | |
| "BUN_mg/dL": { | |
| "icon": "💧", | |
| "title": "Hidrasi & Evaluasi Ginjal", | |
| "color": "orange", | |
| "items": [ | |
| "Tingkatkan asupan cairan untuk membantu ekskresi urea oleh ginjal.", | |
| "Kurangi sementara konsumsi protein hewani berlebih (daging merah, seafood).", | |
| "Pantau output urin harian — penurunan volume bisa menjadi tanda perburukan.", | |
| "Periksa rasio BUN/Kreatinin untuk membedakan penyebab pre-renal vs. renal.", | |
| ], | |
| }, | |
| "Platelet_Count_K/uL": { | |
| "icon": "🩸", | |
| "title": "Pemeriksaan Darah Lanjutan", | |
| "color": "purple", | |
| "items": [ | |
| "Lakukan pemeriksaan darah lengkap (CBC) menyeluruh termasuk morfologi trombosit.", | |
| "Hindari aspirin, ibuprofen, dan antikoagulan tanpa saran dokter.", | |
| "Waspadai tanda perdarahan: lebam berlebih, mimisan, gusi berdarah, atau petekia.", | |
| "Konsultasikan dengan dokter spesialis hematologi untuk evaluasi lebih lanjut.", | |
| ], | |
| }, | |
| } | |
| # CSS | |
| PAGE_CSS = """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap'); | |
| html, body, [data-testid="stAppViewContainer"], [data-testid="stMain"] { | |
| background-color: #f0f7ff !important; | |
| font-family: 'Inter', sans-serif !important; | |
| } | |
| [data-testid="stSidebar"] { display: none !important; } | |
| header[data-testid="stHeader"] { display: none !important; } | |
| footer { display: none !important; } | |
| .block-container { padding: 2.5rem 3rem 4rem !important; max-width: 1060px !important; } | |
| .stButton > button { | |
| background: #2563eb !important; | |
| color: #ffffff !important; | |
| border: none !important; | |
| font-family: 'Inter', sans-serif !important; | |
| font-weight: 600 !important; | |
| font-size: 0.9rem !important; | |
| padding: 0.65rem 1.2rem !important; | |
| border-radius: 8px !important; | |
| width: 100% !important; | |
| transition: background 0.2s !important; | |
| } | |
| .stButton > button:hover { | |
| background: #1d4ed8 !important; | |
| box-shadow: 0 4px 14px rgba(37,99,235,.25) !important; | |
| } | |
| .page-header { | |
| background: linear-gradient(135deg, #eff6ff, #dbeafe); | |
| border: 1px solid #bfdbfe; | |
| border-radius: 14px; | |
| padding: 1.6rem 2rem; | |
| margin-bottom: 2rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 1.2rem; | |
| } | |
| .page-header-icon { font-size: 2.2rem; line-height: 1; } | |
| .page-header-eyebrow { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 0.67rem; | |
| letter-spacing: 0.15em; | |
| color: #3b82f6; | |
| text-transform: uppercase; | |
| margin-bottom: 0.25rem; | |
| } | |
| .page-header-title { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| color: #0f172a; | |
| margin-bottom: 0.2rem; | |
| } | |
| .page-header-sub { font-size: 0.85rem; color: #64748b; } | |
| .section-label { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 0.67rem; | |
| letter-spacing: 0.18em; | |
| color: #3b82f6; | |
| text-transform: uppercase; | |
| border-left: 3px solid #3b82f6; | |
| padding-left: 0.7rem; | |
| margin: 1.6rem 0 0.8rem; | |
| display: block; | |
| } | |
| div[data-testid="stNumberInput"] label, | |
| div[data-testid="stSelectbox"] label, | |
| div[data-testid="stMultiSelect"] label { | |
| font-size: 0.84rem !important; | |
| font-weight: 500 !important; | |
| color: #374151 !important; | |
| } | |
| div[data-testid="stNumberInput"] input { | |
| background: #ffffff !important; | |
| border: 1.5px solid #e2e8f0 !important; | |
| border-radius: 7px !important; | |
| color: #0f172a !important; | |
| font-size: 0.9rem !important; | |
| } | |
| div[data-testid="stNumberInput"] input:focus { | |
| border-color: #3b82f6 !important; | |
| box-shadow: 0 0 0 3px rgba(59,130,246,.12) !important; | |
| } | |
| /* ── Prediction result ── */ | |
| .result-wrap { | |
| border-radius: 14px; | |
| padding: 2rem 2rem 1.6rem; | |
| margin-top: 1.5rem; | |
| position: relative; | |
| overflow: hidden; | |
| border: 1.5px solid; | |
| } | |
| .result-wrap.positive { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fed7aa; } | |
| .result-wrap.negative { background: linear-gradient(135deg, #eff6ff, #dbeafe); border-color: #bfdbfe; } | |
| .result-wrap::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; | |
| height: 4px; | |
| border-radius: 14px 14px 0 0; | |
| } | |
| .result-wrap.positive::before { background: linear-gradient(90deg, #f97316, #ef4444); } | |
| .result-wrap.negative::before { background: linear-gradient(90deg, #3b82f6, #38bdf8); } | |
| .result-meta { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 0.66rem; | |
| letter-spacing: 0.13em; | |
| text-transform: uppercase; | |
| color: #64748b; | |
| margin-bottom: 0.5rem; | |
| } | |
| .result-verdict { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 1.9rem; | |
| font-weight: 700; | |
| line-height: 1; | |
| margin-bottom: 0.4rem; | |
| } | |
| .result-verdict.pos { color: #ea580c; } | |
| .result-verdict.neg { color: #1d4ed8; } | |
| .result-sub { font-size: 0.87rem; color: #475569; margin-bottom: 1.6rem; line-height: 1.55; } | |
| .conf-title { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 0.66rem; | |
| letter-spacing: 0.13em; | |
| text-transform: uppercase; | |
| color: #64748b; | |
| margin-bottom: 0.75rem; | |
| } | |
| .conf-row { display: flex; align-items: center; gap: 0.9rem; margin-bottom: 0.65rem; } | |
| .conf-lbl { font-size: 0.84rem; color: #374151; width: 72px; flex-shrink: 0; font-weight: 500; } | |
| .conf-track { flex: 1; height: 9px; background: #e2e8f0; border-radius: 100px; overflow: hidden; } | |
| .conf-fill { height: 100%; border-radius: 100px; } | |
| .conf-fill.pos { background: linear-gradient(90deg, #f97316, #ef4444); } | |
| .conf-fill.neg { background: linear-gradient(90deg, #3b82f6, #38bdf8); } | |
| .conf-pct { font-family: 'Space Mono', monospace; font-size: 0.84rem; color: #0f172a; width: 50px; text-align: right; flex-shrink: 0; font-weight: 600; } | |
| /* ── Decision path ── */ | |
| .path-wrap { | |
| background: #f8fafc; | |
| border: 1.5px solid #e2e8f0; | |
| border-radius: 14px; | |
| padding: 1.4rem 1.6rem; | |
| margin-top: 0.4rem; | |
| } | |
| .path-title { | |
| font-family: 'Space Mono', monospace; | |
| font-size: 0.66rem; | |
| letter-spacing: 0.13em; | |
| text-transform: uppercase; | |
| color: #64748b; | |
| margin-bottom: 1rem; | |
| } | |
| .path-step { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.65rem; | |
| padding: 0.6rem 1rem; | |
| border-radius: 8px; | |
| margin-bottom: 0.4rem; | |
| font-size: 0.83rem; | |
| font-family: 'Space Mono', monospace; | |
| } | |
| .path-step.triggered { background: #fff7ed; border: 1px solid #fed7aa; color: #92400e; } | |
| .path-step.normal { background: #f0fdf4; border: 1px solid #bbf7d0; color: #14532d; } | |
| .ps-icon { font-size: 1rem; flex-shrink: 0; } | |
| .ps-feat { font-weight: 700; min-width: 115px; } | |
| .ps-val { opacity: 0.95; } | |
| .ps-op { background: rgba(0,0,0,.07); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.76rem; } | |
| .ps-thresh { opacity: 0.7; } | |
| .ps-result { margin-left: auto; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; flex-shrink: 0; } | |
| .path-step.triggered .ps-result { color: #ea580c; } | |
| .path-step.normal .ps-result { color: #16a34a; } | |
| .path-connector { width: 2px; height: 9px; background: #cbd5e1; margin: 0 0 0 1.6rem; } | |
| /* ── Parameter chips ── */ | |
| .param-grid { display: flex; flex-wrap: wrap; gap: 0.55rem; margin-top: 0.4rem; } | |
| .param-chip { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.45rem; | |
| padding: 0.42rem 0.85rem; | |
| border-radius: 100px; | |
| font-size: 0.82rem; | |
| font-weight: 600; | |
| border: 1.5px solid; | |
| } | |
| .param-chip.risk { background: #fff7ed; border-color: #fdba74; color: #92400e; } | |
| .param-chip.ok { background: #f0fdf4; border-color: #86efac; color: #14532d; } | |
| .chip-val { font-family: 'Space Mono', monospace; font-size: 0.79rem; } | |
| /* ── Recommendation cards ── */ | |
| .rec-card { | |
| background: #ffffff; | |
| border: 1.5px solid #e2e8f0; | |
| border-radius: 12px; | |
| padding: 1.2rem 1.4rem; | |
| margin-bottom: 0.7rem; | |
| } | |
| .rec-card.orange { border-left: 4px solid #f97316; } | |
| .rec-card.red { border-left: 4px solid #ef4444; } | |
| .rec-card.purple { border-left: 4px solid #8b5cf6; } | |
| .rec-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; } | |
| .rec-icon { font-size: 1.3rem; line-height: 1; } | |
| .rec-title { font-family: 'Space Mono', monospace; font-size: 0.82rem; font-weight: 700; color: #0f172a; } | |
| .rec-item { | |
| display: flex; | |
| gap: 0.55rem; | |
| font-size: 0.84rem; | |
| color: #374151; | |
| line-height: 1.6; | |
| margin-bottom: 0.38rem; | |
| } | |
| .rec-item::before { content: '→'; color: #3b82f6; font-weight: 700; flex-shrink: 0; margin-top: 0.02rem; } | |
| .no-risk-banner { | |
| background: #f0fdf4; | |
| border: 1.5px solid #bbf7d0; | |
| border-radius: 12px; | |
| padding: 1.1rem 1.4rem; | |
| font-size: 0.87rem; | |
| color: #14532d; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| margin-top: 0.4rem; | |
| } | |
| .disclaimer { | |
| margin-top: 1.4rem; | |
| padding: 0.85rem 1rem; | |
| background: rgba(251,191,36,.1); | |
| border: 1px solid rgba(251,191,36,.35); | |
| border-radius: 8px; | |
| font-size: 0.79rem; | |
| color: #92400e; | |
| line-height: 1.55; | |
| } | |
| </style> | |
| """ | |
| ALL_SYMPTOMS = ["Abdominal Pain","Asymptomatic","Cough","Dyspnea", | |
| "Fatigue","Fever","Headache","Missing","Muscle Aches"] | |
| GENDER_OPTIONS = ["Male","Female"] | |
| REGION_OPTIONS = ["Asia","Europe","North America","South America"] | |
| EXPOSURE_OPTIONS = ["Agricultural","Camping","Dust Exposure", | |
| "Occupational","Rodent Contact","Unknown"] | |
| def build_feature_vector(age, symptom_count, wbc, platelet, alt, ast, | |
| bun, creatinine, gender, region, exposure, symptoms): | |
| num = { | |
| "Age": age, "Symptom_Count": symptom_count, | |
| "WBC_Count_K/uL": wbc, "Platelet_Count_K/uL": platelet, | |
| "ALT_U/L": alt, "AST_U/L": ast, | |
| "BUN_mg/dL": bun, "Creatinine_mg/dL": creatinine, | |
| } | |
| symp_vec = {s: int(s in symptoms) for s in ALL_SYMPTOMS} | |
| gender_vec = {"Gender_F": int(gender=="Female"), "Gender_M": int(gender=="Male")} | |
| region_vec = { | |
| "Region_Asia": int(region=="Asia"), | |
| "Region_Europe": int(region=="Europe"), | |
| "Region_North America": int(region=="North America"), | |
| "Region_South America": int(region=="South America"), | |
| } | |
| exposure_vec = { | |
| "Exposure_Type_Agricultural": int(exposure=="Agricultural"), | |
| "Exposure_Type_Camping": int(exposure=="Camping"), | |
| "Exposure_Type_Dust Exposure": int(exposure=="Dust Exposure"), | |
| "Exposure_Type_Occupational": int(exposure=="Occupational"), | |
| "Exposure_Type_Rodent Contact": int(exposure=="Rodent Contact"), | |
| "Exposure_Type_Unknown": int(exposure=="Unknown"), | |
| } | |
| return pd.DataFrame([{**num, **symp_vec, **gender_vec, **region_vec, **exposure_vec}]) | |
| def conf_bar(label, pct, cls): | |
| return f""" | |
| <div class="conf-row"> | |
| <div class="conf-lbl">{label}</div> | |
| <div class="conf-track"> | |
| <div class="conf-fill {cls}" style="width:{pct:.1f}%"></div> | |
| </div> | |
| <div class="conf-pct">{pct:.1f}%</div> | |
| </div>""" | |
| def get_decision_path(model, X): | |
| """ | |
| Telusuri jalur node yang dilewati model untuk input X. | |
| Hanya tampilkan node yang menggunakan fitur klinis utama (FEATURE_INFO). | |
| """ | |
| try: | |
| tree_clf = model.estimator_ | |
| feat_names = list(X.columns) | |
| tree_ = tree_clf.tree_ | |
| node_indicator = tree_clf.decision_path(X) | |
| leaf_id = tree_clf.apply(X) | |
| node_ids = node_indicator.indices[ | |
| node_indicator.indptr[0]:node_indicator.indptr[1] | |
| ] | |
| steps = [] | |
| for node_id in node_ids: | |
| if leaf_id[0] == node_id: | |
| continue # skip leaf | |
| feat_key = feat_names[tree_.feature[node_id]] | |
| if feat_key not in FEATURE_INFO: | |
| continue # skip non-clinical features | |
| thresh = round(float(tree_.threshold[node_id]), 2) | |
| val = round(float(X.iloc[0][feat_key]), 2) | |
| went_left = val <= thresh | |
| _, unit, _, _, direction = FEATURE_INFO[feat_key] | |
| disp_name = FEATURE_INFO[feat_key][0] | |
| # "is_risk": did this value cross toward the risk side? | |
| is_risk = (not went_left) if direction == "high" else went_left | |
| steps.append({ | |
| "name": disp_name, | |
| "unit": unit, | |
| "threshold": thresh, | |
| "value": val, | |
| "went_left": went_left, | |
| "is_risk": is_risk, | |
| }) | |
| return steps | |
| except Exception: | |
| return [] | |
| def get_triggered_features(creatinine, alt, ast, bun, platelet): | |
| """Return list of feature keys whose values cross the primary tree threshold.""" | |
| checks = { | |
| "Creatinine_mg/dL": (creatinine, TREE_THRESHOLDS["Creatinine_mg/dL"], "high"), | |
| "ALT_U/L": (alt, TREE_THRESHOLDS["ALT_U/L"], "high"), | |
| "AST_U/L": (ast, TREE_THRESHOLDS["AST_U/L"], "high"), | |
| "BUN_mg/dL": (bun, TREE_THRESHOLDS["BUN_mg/dL"], "high"), | |
| "Platelet_Count_K/uL": (platelet, TREE_THRESHOLDS["Platelet_Count_K/uL"], "low"), | |
| } | |
| return [ | |
| feat for feat, (val, thresh, direction) in checks.items() | |
| if (direction == "high" and val > thresh) or (direction == "low" and val < thresh) | |
| ] | |
| def render_decision_path_html(steps): | |
| if not steps: | |
| return "" | |
| html = '<div class="path-wrap"><div class="path-title">Jalur Keputusan Model</div>' | |
| for i, s in enumerate(steps): | |
| cls = "triggered" if s["is_risk"] else "normal" | |
| icon = "⚠️" if s["is_risk"] else "✅" | |
| op = "≤" if s["went_left"] else ">" | |
| result_lbl = "MELEWATI AMBANG" if s["is_risk"] else "DALAM BATAS" | |
| html += f""" | |
| <div class="path-step {cls}"> | |
| <span class="ps-icon">{icon}</span> | |
| <span class="ps-feat">{s['name']}</span> | |
| <span class="ps-val">{s['value']} {s['unit']}</span> | |
| <span class="ps-op">{op} {s['threshold']} {s['unit']}</span> | |
| <span class="ps-result">{result_lbl}</span> | |
| </div>""" | |
| if i < len(steps) - 1: | |
| html += '<div class="path-connector"></div>' | |
| html += "</div>" | |
| return html | |
| def render_param_chips_html(creatinine, alt, ast, bun, platelet, triggered): | |
| params = [ | |
| ("Creatinine_mg/dL", creatinine), | |
| ("ALT_U/L", alt), | |
| ("AST_U/L", ast), | |
| ("BUN_mg/dL", bun), | |
| ("Platelet_Count_K/uL", platelet), | |
| ] | |
| chips = "" | |
| for feat, val in params: | |
| name, unit, _, _, _ = FEATURE_INFO[feat] | |
| cls = "risk" if feat in triggered else "ok" | |
| badge = "⚠️" if feat in triggered else "✅" | |
| chips += f'<div class="param-chip {cls}">{badge} {name} <span class="chip-val">{val} {unit}</span></div>' | |
| return f'<div class="param-grid">{chips}</div>' | |
| def render_recommendations_html(triggered): | |
| if not triggered: | |
| return ( | |
| '<div class="no-risk-banner">✅ ' | |
| '<span>Semua parameter klinis utama berada dalam batas ambang tree model. ' | |
| 'Tidak ada rekomendasi khusus yang diperlukan saat ini.</span></div>' | |
| ) | |
| html = "" | |
| for feat in triggered: | |
| rec = RECOMMENDATIONS[feat] | |
| items = "".join(f'<div class="rec-item">{it}</div>' for it in rec["items"]) | |
| html += f""" | |
| <div class="rec-card {rec['color']}"> | |
| <div class="rec-header"> | |
| <span class="rec-icon">{rec['icon']}</span> | |
| <span class="rec-title">{rec['title']}</span> | |
| </div> | |
| {items} | |
| </div>""" | |
| return html | |
| # Main page | |
| def render(goto): | |
| st.markdown(PAGE_CSS, unsafe_allow_html=True) | |
| if st.button("← Kembali ke Home", key="back_var"): | |
| goto("home") | |
| st.markdown(""" | |
| <div class="page-header"> | |
| <div class="page-header-icon">📋</div> | |
| <div> | |
| <div class="page-header-eyebrow">Model 01 · HantaVarNet</div> | |
| <div class="page-header-title">Input Variabel Klinis</div> | |
| <div class="page-header-sub"> | |
| Isi seluruh data klinis pasien. Semua field wajib diisi sebelum prediksi dijalankan. | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Numerik | |
| st.markdown('<span class="section-label">📊 Variabel Numerik</span>', unsafe_allow_html=True) | |
| c1, c2, c3, c4 = st.columns(4) | |
| with c1: age = st.number_input("Age (tahun)", min_value=5, max_value=90, value=30, step=1) | |
| with c2: symptom_count = st.number_input("Symptom Count", min_value=0, max_value=5, value=2, step=1) | |
| with c3: wbc = st.number_input("WBC Count (K/uL)", min_value=1.0, max_value=20.0, value=7.0, step=0.1, format="%.1f") | |
| with c4: platelet = st.number_input("Platelet Count (K/uL)", min_value=1.0, max_value=500.0, value=200.0, step=1.0, format="%.1f") | |
| c5, c6, c7, c8 = st.columns(4) | |
| with c5: alt = st.number_input("ALT (U/L)", min_value=5.0, max_value=300.0, value=35.0, step=1.0, format="%.1f") | |
| with c6: ast = st.number_input("AST (U/L)", min_value=5.0, max_value=300.0, value=35.0, step=1.0, format="%.1f") | |
| with c7: bun = st.number_input("BUN (mg/dL)", min_value=5.0, max_value=100.0, value=15.0, step=0.5, format="%.1f") | |
| with c8: creatinine = st.number_input("Creatinine (mg/dL)", min_value=0.1, max_value=10.0, value=1.0, step=0.1, format="%.1f") | |
| # Gejala | |
| st.markdown('<span class="section-label">🤒 Gejala Klinis</span>', unsafe_allow_html=True) | |
| symptoms = st.multiselect( | |
| "Pilih semua gejala yang dialami pasien (boleh lebih dari satu)", | |
| options=ALL_SYMPTOMS, default=[], | |
| placeholder="Klik untuk memilih gejala…", | |
| ) | |
| # Kategorikal | |
| st.markdown('<span class="section-label">🗂️ Data Kategorikal</span>', unsafe_allow_html=True) | |
| cg, cr, ce = st.columns(3) | |
| with cg: gender = st.selectbox("Gender", options=GENDER_OPTIONS) | |
| with cr: region = st.selectbox("Region", options=REGION_OPTIONS) | |
| with ce: exposure = st.selectbox("Exposure Type", options=EXPOSURE_OPTIONS) | |
| # Predict | |
| st.markdown("<div style='height:0.6rem'></div>", unsafe_allow_html=True) | |
| _, btn_col, _ = st.columns([1, 4, 1]) | |
| with btn_col: | |
| predict_clicked = st.button("Jalankan Prediksi", key="predict_var", use_container_width=True) | |
| if not predict_clicked: | |
| return | |
| # Load & predict | |
| try: | |
| model = load_model() | |
| except FileNotFoundError: | |
| st.error(f"Model tidak ditemukan di: {MODEL_PATH}") | |
| return | |
| X = build_feature_vector(age, symptom_count, wbc, platelet, | |
| alt, ast, bun, creatinine, | |
| gender, region, exposure, symptoms) | |
| fn = None | |
| if hasattr(model, "feature_names_in_"): | |
| fn = model.feature_names_in_ | |
| elif hasattr(model, "estimator_") and hasattr(model.estimator_, "feature_names_in_"): | |
| fn = model.estimator_.feature_names_in_ | |
| if fn is not None: | |
| for col in fn: | |
| if col not in X.columns: | |
| X[col] = 0 | |
| X = X[fn] | |
| pred = model.predict(X)[0] | |
| proba = model.predict_proba(X)[0] | |
| is_pos = int(pred) == 1 | |
| wrap_cls = "positive" if is_pos else "negative" | |
| verd_cls = "pos" if is_pos else "neg" | |
| verdict_txt = "POSITIF HANTAVIRUS" if is_pos else "NEGATIF HANTAVIRUS" | |
| verdict_sub = ( | |
| "Pasien terindikasi terinfeksi Hantavirus. Segera lakukan konfirmasi laboratorium lebih lanjut." | |
| if is_pos else | |
| "Tidak terdeteksi indikasi infeksi Hantavirus berdasarkan data klinis yang diinput." | |
| ) | |
| # 1. Prediction card | |
| st.markdown(f""" | |
| <div class="result-wrap {wrap_cls}"> | |
| <div class="result-meta">Hasil Prediksi · HantaVarNet (Decision Tree + Threshold Tuning)</div> | |
| <div class="result-verdict {verd_cls}">{verdict_txt}</div> | |
| <div class="result-sub">{verdict_sub}</div> | |
| <div class="conf-title">Confidence Score</div> | |
| {conf_bar("Negatif", proba[0]*100, "neg")} | |
| {conf_bar("Positif", proba[1]*100, "pos")} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 2. Decision path | |
| st.markdown('<span class="section-label">🌿 Alasan Prediksi — Jalur Tree</span>', unsafe_allow_html=True) | |
| path_steps = get_decision_path(model, X) | |
| if path_steps: | |
| st.markdown(render_decision_path_html(path_steps), unsafe_allow_html=True) | |
| else: | |
| st.info("Jalur keputusan tidak tersedia untuk input ini.") | |
| # 3. Parameter chips | |
| triggered = get_triggered_features(creatinine, alt, ast, bun, platelet) | |
| st.markdown('<span class="section-label">📋 Ringkasan Parameter</span>', unsafe_allow_html=True) | |
| st.markdown(render_param_chips_html(creatinine, alt, ast, bun, platelet, triggered), unsafe_allow_html=True) | |
| # 4. Recommendations | |
| st.markdown('<span class="section-label">💊 Rekomendasi Klinis</span>', unsafe_allow_html=True) | |
| st.markdown(render_recommendations_html(triggered), unsafe_allow_html=True) | |
| # 5. Disclaimer | |
| st.markdown(""" | |
| <div class="disclaimer"> | |
| ⚠️ <strong>Perhatian:</strong> Hasil prediksi dan rekomendasi ini bersifat indikatif dan | |
| <strong>tidak menggantikan diagnosis klinis</strong> oleh tenaga medis profesional. | |
| Rekomendasi dihasilkan berdasarkan aturan ambang batas dari Decision Tree yang ditraining, | |
| bukan dari evaluasi medis langsung. Gunakan sebagai alat bantu skrining awal saja. | |
| </div> | |
| """, unsafe_allow_html=True) |