# -*- coding: utf-8 -*- """Enhanced CI Outcome Predictor with Advanced UI (HF Spaces Ready)""" import os import re import pickle import joblib import numpy as np import pandas as pd import gradio as gr from pathlib import Path # ========================= # PATHS (Hugging Face Spaces / repo root) # Place these files in the SAME folder as app.py: # - validation_data.csv # - Cochlear_Implant_Dataset.csv # - ci_success_classifier.pkl # - ci_speech_score_regressor.pkl # ========================= BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd() VAL_CSV_PATH = BASE_DIR / "validation_data.csv" MAIN_CSV_PATH = BASE_DIR / "Cochlear_Implant_Dataset.csv" CLF_PKL_PATH = BASE_DIR / "ci_success_classifier.pkl" REG_PKL_PATH = BASE_DIR / "ci_speech_score_regressor.pkl" # Writable location on HF Spaces BATCH_OUT_PATH = Path("/tmp/predictions_output.csv") def _require(p: Path) -> bool: return p.exists() and p.is_file() def _missing_files_message(): missing = [] for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]: if not _require(p): missing.append(p.name) return f"""
⚠️ Setup required: Missing required files in repo root:
{"
".join(missing) if missing else "β€”"}
Upload the files to the same folder as app.py and restart the Space.
""" # ========================= # Load data + models (guarded so Space can boot even if files missing) # ========================= APP_READY = all(_require(p) for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]) val_df = pd.DataFrame() main_df = pd.DataFrame() clf_model = None reg_model = None if APP_READY: val_df = pd.read_csv(VAL_CSV_PATH) main_df = pd.read_csv(MAIN_CSV_PATH) def load_model(path: Path): try: return joblib.load(path) except Exception: with open(path, "rb") as f: return pickle.load(f) clf_model = load_model(CLF_PKL_PATH) reg_model = load_model(REG_PKL_PATH) def get_model_feature_names(m): if m is None: return None if hasattr(m, "feature_names_in_"): return list(getattr(m, "feature_names_in_")) if hasattr(m, "named_steps"): for step in m.named_steps.values(): if hasattr(step, "feature_names_in_"): return list(step.feature_names_in_) return None clf_expected = get_model_feature_names(clf_model) or [] reg_expected = get_model_feature_names(reg_model) or [] # Union of expected columns (preserve order) input_cols = [] for colset in [clf_expected, reg_expected]: for c in colset: if c not in input_cols: input_cols.append(c) if not input_cols and APP_READY: input_cols = list(val_df.columns) # ========================= # Build Gene dropdown choices from MAIN dataset # ========================= def find_gene_column(df: pd.DataFrame): if df is None or df.empty: return None if "Gene" in df.columns: return "Gene" for c in df.columns: if "gene" in c.lower(): return c return None def normalize_str_series(s: pd.Series) -> pd.Series: return ( s.astype(str) .str.strip() .replace({"null": np.nan, "NULL": np.nan, "None": np.nan, "none": np.nan, "": np.nan, "nan": np.nan, "NaN": np.nan}) ) gene_choices = [] if APP_READY: gene_col_main = find_gene_column(main_df) if gene_col_main is not None: gene_choices = sorted(set(normalize_str_series(main_df[gene_col_main]).dropna().tolist())) if not gene_choices: gene_col_val = find_gene_column(val_df) if gene_col_val is not None: gene_choices = sorted(set(normalize_str_series(val_df[gene_col_val]).dropna().tolist())) # ========================= # Helpers # ========================= def parse_age_to_years(age_raw: str, mode: str): """ mode: - "Years.Months (1.11 = 1y 11m)" -> 1 + 11/12 - "Decimal (1.11 = 1.11 years)" -> 1.11 Accepts "1.6YRS", "2yrs", etc. """ if age_raw is None: return np.nan s = str(age_raw).strip() if s == "" or s.lower() in {"nan", "none", "null"}: return np.nan cleaned = re.sub(r"[^0-9\.]", "", s) if mode.startswith("Decimal"): try: return float(cleaned) except: return np.nan # Years.Months mode if cleaned.count(".") == 1: a, b = cleaned.split(".") if a.isdigit() and b.isdigit() and len(b) == 2: years = int(a) months = int(b) if 0 <= months <= 11: return years + months / 12.0 # fallback to decimal try: return float(cleaned) except: return np.nan try: return float(cleaned) except: return np.nan def safe_pct(x): try: return int(round(float(x) * 100)) except: return None def get_gene_feature_name(cols): for c in cols: if c.lower() == "gene": return c for c in cols: if "gene" in c.lower(): return c return None def get_age_feature_names(cols): return [c for c in cols if "age" in c.lower()] GENE_FEAT = get_gene_feature_name(input_cols) if APP_READY else None AGE_FEATS = get_age_feature_names(input_cols) if APP_READY else [] def align_to_expected(df: pd.DataFrame, expected_cols): if not expected_cols: return df out = df.copy() for c in expected_cols: if c not in out.columns: out[c] = np.nan return out[expected_cols] def render_single_result_html(gene, age_entered, age_used_years, parse_mode, label, prob, speech): if label == 1: status = "Likely Success" badge = "ok" icon = "βœ“" emoji = "πŸŽ‰" elif label == 0: status = "Lower Likelihood" badge = "warn" icon = "!" emoji = "⚠️" else: status = "Unavailable" badge = "neutral" icon = "?" emoji = "❓" prob_pct = safe_pct(prob) if prob is not None else None prob_text = f"{prob_pct}%" if prob_pct is not None else "β€”" bar_width = f"{prob_pct}%" if prob_pct is not None else "0%" try: speech_disp = f"{float(speech):.3f}" except: speech_disp = "β€”" age_used_disp = f"{float(age_used_years):.3f} years" if np.isfinite(age_used_years) else "β€”" gene_disp = str(gene) if gene is not None else "β€”" return f"""
🧬
Gene
{gene_disp}
πŸ“…
Age Entered
{age_entered}
πŸ”¬
Model Age
{age_used_disp}
πŸ“Š
Parse Mode
{parse_mode.split('(')[0].strip()}
Success Probability {prob_text}
0%25%50%75%100%
Predicted Label
{label}
Speech Score
{speech_disp}
Informational tool only. Not medical advice. Consult healthcare professionals for clinical decisions.
""" def predict_single(gene, age_text, parse_mode): if not APP_READY: return _missing_files_message() if gene is None or str(gene).strip() == "": raise gr.Error("Please select a Gene.") age_used = parse_age_to_years(age_text, parse_mode) if not (isinstance(age_used, (float, np.floating)) and np.isfinite(age_used)): raise gr.Error("Please enter a valid Age (e.g., 1.6YRS, 1.11, 2.3).") row = {} for c in input_cols: if GENE_FEAT and c == GENE_FEAT: row[c] = gene elif c in AGE_FEATS: row[c] = age_used else: row[c] = np.nan X = pd.DataFrame([row]) Xc = align_to_expected(X, clf_expected) Xr = align_to_expected(X, reg_expected) label = int(clf_model.predict(Xc)[0]) prob = None if hasattr(clf_model, "predict_proba"): p = clf_model.predict_proba(Xc)[0] if len(p) >= 2: prob = float(p[1]) speech = reg_model.predict(Xr)[0] return render_single_result_html(gene, age_text, age_used, parse_mode, label, prob, speech) def _file_to_path(file_obj): """ Gradio v3/v4 compatibility: - Sometimes a string path - Sometimes object with .name - In Gradio 4: often has .path - Sometimes dict-like with 'name' or 'path' """ if file_obj is None: return None if isinstance(file_obj, str): return file_obj if hasattr(file_obj, "path") and file_obj.path: return file_obj.path if hasattr(file_obj, "name") and file_obj.name: return file_obj.name if isinstance(file_obj, dict): if file_obj.get("path"): return file_obj["path"] if file_obj.get("name"): return file_obj["name"] return None def predict_batch(csv_file, parse_mode): if not APP_READY: raise gr.Error("Missing required model/data files in repo root. Please upload them and restart the Space.") path = _file_to_path(csv_file) if not path: raise gr.Error("Please upload a CSV file.") df = pd.read_csv(path) if df.empty: raise gr.Error("Uploaded CSV is empty.") df_cols_lower = {c.lower(): c for c in df.columns} gene_col = None if GENE_FEAT and GENE_FEAT.lower() in df_cols_lower: gene_col = df_cols_lower[GENE_FEAT.lower()] else: for c in df.columns: if "gene" in c.lower(): gene_col = c break if gene_col is None: raise gr.Error("CSV must include a Gene column (e.g., 'Gene').") age_source_col = None for c in df.columns: if "age" in c.lower(): age_source_col = c break if age_source_col is None: raise gr.Error("CSV must include an Age column (e.g., 'Age').") X = pd.DataFrame(index=df.index) parsed_age = df[age_source_col].apply(lambda v: parse_age_to_years(v, parse_mode)) if parsed_age.isna().any(): bad_n = int(parsed_age.isna().sum()) raise gr.Error(f"{bad_n} rows have invalid Age values for the selected parsing mode.") for col in input_cols: if GENE_FEAT and col == GENE_FEAT: X[col] = df[gene_col] elif col in AGE_FEATS: X[col] = parsed_age else: src = df_cols_lower.get(col.lower()) X[col] = df[src] if src is not None else np.nan Xc = align_to_expected(X, clf_expected) Xr = align_to_expected(X, reg_expected) out = df.copy() out["success_label_pred"] = clf_model.predict(Xc) if hasattr(clf_model, "predict_proba"): proba = clf_model.predict_proba(Xc) if proba.shape[1] == 2: out["success_prob_class1"] = proba[:, 1] out["speech_score_pred"] = reg_model.predict(Xr) out.to_csv(BATCH_OUT_PATH, index=False) n = len(out) succ = int((out["success_label_pred"] == 1).sum()) succ_pct = int(round((succ / n) * 100)) if n else 0 avg_prob_txt = "β€”" if "success_prob_class1" in out.columns: try: avg_prob_txt = f"{int(round(float(out['success_prob_class1'].mean())*100))}%" except: pass avg_speech_txt = "β€”" try: avg_speech_txt = f"{float(pd.to_numeric(out['speech_score_pred'], errors='coerce').mean()):.3f}" except: pass summary = f"""
πŸ“Š Batch Analysis Complete
{n} rows processed
βœ“
Predicted Success
{succ}
{succ_pct}% of total
🎯
Avg Probability
{avg_prob_txt}
🎀
Avg Speech Score
{avg_speech_txt}
βš™οΈ
Parse Mode
{parse_mode.split('(')[0].strip()}
Download the complete results CSV below
""" return summary, out.head(20), str(BATCH_OUT_PATH) def age_preview(age_text, parse_mode): v = parse_age_to_years(age_text, parse_mode) if isinstance(v, (float, np.floating)) and np.isfinite(v): return f"""
✨
Model will use
{v:.3f} years
""" return """
❌
Model will use
Invalid
""" # ========================= # CSS + JS (UNCHANGED from your provided script) # ========================= # ========================= # ENHANCED CSS WITH LIGHT/DARK MODE & ANIMATIONS # ========================= CSS = """ /* ========== THEME VARIABLES ========== */ :root { --bg-primary: #f8fafc; --bg-secondary: #ffffff; --bg-tertiary: #f1f5f9; --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; --border-color: #e2e8f0; --accent-primary: #3b82f6; --accent-secondary: #8b5cf6; --success-color: #10b981; --warning-color: #f59e0b; --danger-color: #ef4444; --shadow-sm: 0 1px 3px rgba(0,0,0,0.05); --shadow-md: 0 4px 12px rgba(0,0,0,0.08); --shadow-lg: 0 10px 30px rgba(0,0,0,0.12); --shadow-xl: 0 20px 50px rgba(0,0,0,0.15); --radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px; --radius-xl: 24px; --transition-fast: 0.15s ease; --transition-normal: 0.3s ease; --transition-slow: 0.5s ease; } [data-theme="dark"] { --bg-primary: #0f172a; --bg-secondary: #1e293b; --bg-tertiary: #334155; --text-primary: #f1f5f9; --text-secondary: #cbd5e1; --text-muted: #64748b; --border-color: #334155; --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); --shadow-md: 0 4px 12px rgba(0,0,0,0.4); --shadow-lg: 0 10px 30px rgba(0,0,0,0.5); --shadow-xl: 0 20px 50px rgba(0,0,0,0.6); } /* ========== BASE STYLES ========== */ * { transition: background-color var(--transition-normal), border-color var(--transition-normal), color var(--transition-normal); } .gradio-container { background: var(--bg-primary) !important; color: var(--text-primary) !important; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; } /* Hide Gradio footer */ footer, .footer, #footer, .gradio-footer { display: none !important; } /* ========== ANIMATIONS ========== */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } @keyframes slideInRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } } .fade-in-up { animation: fadeInUp 0.6s ease-out; } .fade-in { animation: fadeIn 0.4s ease-out; } .scale-in { animation: scaleIn 0.4s ease-out; } .slide-in-right { animation: slideInRight 0.5s ease-out; } /* ========== GLASS EFFECT ========== */ .glass-effect { background: rgba(255, 255, 255, 0.7) !important; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.3); } [data-theme="dark"] .glass-effect { background: rgba(30, 41, 59, 0.7) !important; border: 1px solid rgba(255, 255, 255, 0.1); } /* ========== HOVER EFFECTS ========== */ .hover-lift { transition: transform var(--transition-normal), box-shadow var(--transition-normal); } .hover-lift:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg) !important; } /* ========== HEADER ========== */ .app-header { background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); padding: 48px 32px; border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); margin-bottom: 32px; position: relative; overflow: hidden; animation: fadeInUp 0.6s ease-out; } .app-header::before { content: ''; position: absolute; top: -50%; right: -10%; width: 300px; height: 300px; background: rgba(255, 255, 255, 0.1); border-radius: 50%; animation: pulse 3s ease-in-out infinite; } .header-content { position: relative; z-index: 1; } .header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .header-title { display: flex; align-items: center; gap: 16px; } .header-icon { font-size: 48px; animation: scaleIn 0.8s ease-out; } .header-text h1 { margin: 0; font-size: 36px; font-weight: 800; color: white; letter-spacing: -0.5px; text-shadow: 0 2px 10px rgba(0,0,0,0.1); } .header-text .subtitle { margin: 8px 0 0; font-size: 16px; color: rgba(255, 255, 255, 0.9); font-weight: 500; } .header-stats { display: flex; gap: 24px; margin-top: 24px; } .header-stat { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); padding: 16px 24px; border-radius: var(--radius-md); border: 1px solid rgba(255, 255, 255, 0.2); } .header-stat-label { font-size: 12px; color: rgba(255, 255, 255, 0.8); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; } .header-stat-value { font-size: 24px; color: white; font-weight: 800; margin-top: 4px; } /* ========== THEME TOGGLE ========== */ #theme-toggle-btn { background: rgba(255, 255, 255, 0.2) !important; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 50px !important; padding: 12px 24px !important; cursor: pointer; transition: all var(--transition-fast) !important; display: flex; align-items: center; gap: 8px; } /* Light theme: black text */ :root #theme-toggle-btn, :root #theme-toggle-btn button { color: #0f172a !important; } /* Dark theme: white text */ [data-theme="dark"] #theme-toggle-btn, [data-theme="dark"] #theme-toggle-btn button { color: #ffffff !important; } #theme-toggle-btn:hover { background: rgba(255, 255, 255, 0.3) !important; transform: scale(1.05); } /* ========== CARDS & CONTAINERS ========== */ .input-card, .output-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; box-shadow: var(--shadow-md); animation: fadeInUp 0.6s ease-out; } .section-title { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; display: flex; align-items: center; gap: 10px; } .section-title::before { content: ''; width: 4px; height: 24px; background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary)); border-radius: 2px; } /* ========== RESULT CARDS ========== */ .result-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-xl); padding: 0; box-shadow: var(--shadow-lg); overflow: hidden; } .result-banner { padding: 24px 28px; display: flex; align-items: center; gap: 16px; position: relative; overflow: hidden; } .result-banner::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; opacity: 0.1; z-index: 0; } .result-banner.ok { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } .result-banner.warn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); } .result-banner.neutral { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); } .banner-emoji { font-size: 48px; position: relative; z-index: 1; animation: scaleIn 0.6s ease-out; } .banner-text { flex: 1; position: relative; z-index: 1; } .banner-title { font-size: 24px; font-weight: 800; color: white; margin-bottom: 4px; } .banner-sub { font-size: 14px; color: rgba(255, 255, 255, 0.9); font-weight: 500; } .pulse-dot { width: 12px; height: 12px; background: white; border-radius: 50%; position: relative; z-index: 1; animation: pulse 2s ease-in-out infinite; } /* ========== METRICS GRID ========== */ .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; padding: 24px; } .metric-card { background: var(--bg-tertiary); border-radius: var(--radius-md); padding: 20px; text-align: center; border: 1px solid var(--border-color); } .metric-icon { font-size: 32px; margin-bottom: 12px; } .metric-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 8px; } .metric-value { font-size: 18px; font-weight: 800; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Courier New', monospace; } .metric-value.small { font-size: 14px; } /* ========== PROBABILITY SECTION ========== */ .probability-section { margin: 24px; padding: 24px; background: var(--bg-tertiary); border-radius: var(--radius-lg); border: 1px solid var(--border-color); } .prob-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .prob-title { font-size: 16px; font-weight: 700; color: var(--text-primary); } .prob-value-large { font-size: 32px; font-weight: 800; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .prob-bar-container { margin-top: 16px; } .prob-bar-track { height: 16px; background: var(--bg-secondary); border-radius: 999px; overflow: hidden; border: 2px solid var(--border-color); position: relative; } .prob-bar-fill { height: 100%; border-radius: 999px; position: relative; overflow: hidden; transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); } .prob-bar-fill.ok { background: linear-gradient(90deg, #10b981, #059669); } .prob-bar-fill.warn { background: linear-gradient(90deg, #f59e0b, #d97706); } .prob-bar-fill.neutral { background: linear-gradient(90deg, #3b82f6, #2563eb); } .shimmer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient( 90deg, transparent, rgba(255, 255, 255, 0.3), transparent ); animation: shimmer 2s infinite; } .prob-markers { display: flex; justify-content: space-between; margin-top: 8px; font-size: 11px; color: var(--text-muted); font-weight: 600; } /* ========== DETAILS GRID ========== */ .details-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; padding: 0 24px 24px; } .detail-box { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px; } .detail-icon { width: 48px; height: 48px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .detail-icon.ok { background: linear-gradient(135deg, #10b981, #059669); color: white; } .detail-icon.warn { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; } .detail-icon.neutral { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; } .detail-content { flex: 1; } .detail-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 4px; } .detail-value { font-size: 20px; font-weight: 800; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Courier New', monospace; } /* ========== DISCLAIMER ========== */ .disclaimer { margin: 0 24px 24px; padding: 16px; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: var(--radius-md); display: flex; align-items: flex-start; gap: 12px; font-size: 13px; color: var(--text-secondary) !important; line-height: 1.5; } /* Force disclaimer text to follow theme */ [data-theme="dark"] .disclaimer, [data-theme="dark"] .disclaimer span { color: var(--text-secondary) !important; } /* Keep icon accent color (don’t override in dark mode) */ .disclaimer svg { color: var(--accent-primary) !important; flex-shrink: 0; margin-top: 2px; } /* ========== BATCH RESULTS ========== */ .batch-results { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-xl); overflow: hidden; box-shadow: var(--shadow-lg); } .batch-header { background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); padding: 24px 28px; display: flex; justify-content: space-between; align-items: center; } .batch-title { display: flex; align-items: center; gap: 12px; font-size: 24px; font-weight: 800; color: white; } .batch-icon { font-size: 32px; } .batch-count { background: rgba(255, 255, 255, 0.2); padding: 8px 16px; border-radius: 999px; font-size: 14px; color: white; font-weight: 600; border: 1px solid rgba(255, 255, 255, 0.3); } .stats-showcase { padding: 24px; } .stat-mega { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 32px; margin-bottom: 20px; display: flex; align-items: center; gap: 24px; position: relative; overflow: hidden; } .stat-mega-icon { width: 64px; height: 64px; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; font-size: 32px; color: white; flex-shrink: 0; } .stat-mega-icon.ok { background: linear-gradient(135deg, #10b981, #059669); } .stat-mega-content { flex: 1; } .stat-mega-label { font-size: 14px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 8px; } .stat-mega-value { font-size: 48px; font-weight: 800; color: var(--text-primary); line-height: 1; margin-bottom: 8px; } .stat-mega-sub { font-size: 16px; color: var(--text-secondary); font-weight: 500; } .stat-ring { width: 100px; height: 100px; position: relative; } .stat-ring svg { transform: rotate(-90deg); width: 100%; height: 100%; } .ring-bg { fill: none; stroke: var(--border-color); stroke-width: 8; } .ring-fill { fill: none; stroke: url(#gradient); stroke-width: 8; stroke-linecap: round; stroke-dasharray: 283; transition: stroke-dashoffset 1s ease; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; } .stat-card { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; text-align: center; } .stat-icon { font-size: 32px; margin-bottom: 12px; } .stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 8px; } .stat-value { font-size: 24px; font-weight: 800; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Courier New', monospace; } .stat-value.small { font-size: 16px; } .download-prompt { margin-top: 24px; padding: 20px; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; gap: 12px; font-size: 14px; color: var(--text-primary); font-weight: 600; } .download-prompt svg { color: var(--accent-primary); } /* ========== AGE PREVIEW ========== */ .age-preview-box { margin-top: 12px; padding: 16px 20px; background: var(--bg-tertiary); border: 2px dashed var(--border-color); border-radius: var(--radius-md); display: flex; align-items: center; gap: 12px; } .preview-icon { font-size: 24px; } .preview-content { flex: 1; } .preview-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 4px; } .preview-value { font-size: 18px; font-weight: 800; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Courier New', monospace; } /* ========== BUTTONS ========== */ #predict-btn button, #batch-btn button { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important; color: white !important; border: none !important; border-radius: var(--radius-md) !important; padding: 16px 32px !important; font-size: 16px !important; font-weight: 700 !important; box-shadow: var(--shadow-md) !important; transition: all var(--transition-fast) !important; } #predict-btn button:hover, #batch-btn button:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg) !important; } /* ========== TABS ========== */ .tabs { border-bottom: 2px solid var(--border-color); margin-bottom: 24px; } .tab-nav button { background: transparent !important; border: none !important; border-bottom: 3px solid transparent !important; color: var(--text-secondary) !important; font-weight: 600 !important; padding: 12px 24px !important; transition: all var(--transition-fast) !important; } .tab-nav button.selected { color: var(--accent-primary) !important; border-bottom-color: var(--accent-primary) !important; } /* ========== INFO BOX ========== */ .info-box { background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); border-radius: var(--radius-md); padding: 16px 20px; margin-bottom: 20px; } .info-box strong { color: var(--accent-secondary); font-weight: 700; } /* Ensure info-box text follows theme */ .info-box, .info-box * { color: var(--text-secondary) !important; } /* Keep strong highlighted */ .info-box strong { color: var(--accent-secondary) !important; } /* ========== RESPONSIVE ========== */ @media (max-width: 768px) { .header-top { flex-direction: column; align-items: flex-start; gap: 16px; } .header-stats { flex-direction: column; width: 100%; } .header-stat { width: 100%; } .header-text h1 { font-size: 28px; } .metrics-grid { grid-template-columns: 1fr; } .details-grid { grid-template-columns: 1fr; } .stat-mega { flex-direction: column; text-align: center; } .stats-grid { grid-template-columns: 1fr; } .batch-header { flex-direction: column; align-items: flex-start; gap: 12px; } } /* ========== GRADIO OVERRIDES ========== */ .gr-button-primary { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important; border: none !important; } .gr-input, .gr-box { border-color: var(--border-color) !important; background: var(--bg-secondary) !important; } .gr-form { background: var(--bg-secondary) !important; border-color: var(--border-color) !important; } .gr-panel { background: var(--bg-secondary) !important; border-color: var(--border-color) !important; } /* ============================== FORCE BUTTON TEXT COLORS ============================== */ #predict-btn button, #batch-btn button, #predict-btn button * , #batch-btn button * { color: #ffffff !important; } """ JS = r""" function() { const root = document.documentElement; const currentTheme = root.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; root.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); const btn = document.querySelector('#theme-toggle-btn button'); if (btn) { btn.textContent = newTheme === 'dark' ? 'β˜€οΈ Light Mode' : 'πŸŒ™ Dark Mode'; } return newTheme; } """ INIT_JS = r""" function() { const savedTheme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', savedTheme); const btn = document.querySelector('#theme-toggle-btn button'); if (btn) { btn.textContent = savedTheme === 'dark' ? 'β˜€οΈ Light Mode' : 'πŸŒ™ Dark Mode'; } } """ theme = gr.themes.Soft( primary_hue="blue", secondary_hue="purple", neutral_hue="slate", radius_size="lg", font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], ) with gr.Blocks(theme=theme, css=CSS, js=INIT_JS, title="CI Outcome Predictor") as demo: gr.HTML(f"""
πŸ”¬

CI Outcome Predictor

Advanced ML-powered Cochlear Implant Success Analysis
Gene Options
{len(gene_choices) if APP_READY else 0}
Model Features
{len(input_cols) if APP_READY else 0}
Prediction Models
2
""") theme_state = gr.State("light") theme_btn = gr.Button("πŸŒ™ Dark Mode", elem_id="theme-toggle-btn") theme_btn.click(fn=None, js=JS, outputs=theme_state) with gr.Tabs(): with gr.Tab("🎯 Single Prediction"): with gr.Row(): with gr.Column(scale=1): gr.HTML('
πŸ“ Input Parameters
') gene_in = gr.Dropdown( choices=gene_choices, value=gene_choices[0] if gene_choices else None, label="🧬 Select Gene", info="Choose the gene variant for analysis", filterable=True, ) age_in = gr.Textbox( label="πŸ“… Patient Age", placeholder="Examples: 1.11 | 1.6YRS | 2.3", info="Enter age in supported format" ) parse_mode = gr.Radio( choices=[ "Decimal (1.11 = 1.11 years)", "Years.Months (1.11 = 1y 11m)" ], value="Decimal (1.11 = 1.11 years)", label="βš™οΈ Age Parsing Mode", info="Select how age should be interpreted" ) age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)")) btn = gr.Button("πŸš€ Run Prediction", elem_id="predict-btn", size="lg") with gr.Column(scale=1): gr.HTML('
πŸ“Š Prediction Results
') single_out = gr.HTML( value=_missing_files_message() if not APP_READY else "
πŸ‘ˆ Enter parameters and click 'Run Prediction' to see results
" ) age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint]) parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint]) btn.click(fn=predict_single, inputs=[gene_in, age_in, parse_mode], outputs=[single_out]) with gr.Tab("πŸ“Š Batch Prediction (CSV)"): gr.HTML('
πŸ“ Batch Processing
') gr.HTML(f"""
πŸ“‹ Requirements: Your CSV must include Gene and Age columns. Additional features will be auto-filled. Model expects {len(input_cols) if APP_READY else 0} total feature columns.
""") parse_mode_b = gr.Radio( choices=[ "Decimal (1.11 = 1.11 years)", "Years.Months (1.11 = 1y 11m)" ], value="Decimal (1.11 = 1.11 years)", label="βš™οΈ Age Parsing Mode", info="How should ages be interpreted?" ) csv_in = gr.File(file_types=[".csv"], label="πŸ“€ Upload CSV File", file_count="single") run_b = gr.Button("πŸš€ Run Batch Prediction", elem_id="batch-btn", size="lg") batch_summary = gr.HTML(value="") gr.HTML('
πŸ‘€ Preview (First 20 Rows)
') batch_preview = gr.Dataframe(headers=None, interactive=False, wrap=True) gr.HTML('
πŸ’Ύ Download Results
') batch_file = gr.File(label="πŸ“₯ Download Full Results CSV") run_b.click(fn=predict_batch, inputs=[csv_in, parse_mode_b], outputs=[batch_summary, batch_preview, batch_file]) if __name__ == "__main__": # HF Spaces-safe launch (no share=True) port = int(os.getenv("PORT", "7860")) demo.launch(server_name="0.0.0.0", server_port=port, show_error=False)