techatcreated's picture
Update app.py
31c6e25 verified
# -*- 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"""
<div class="info-box">
<strong>⚠️ Setup required:</strong> Missing required files in repo root:<br/>
<div style="margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;">
{"<br/>".join(missing) if missing else "β€”"}
</div>
<div style="margin-top:10px; opacity:0.9;">
Upload the files to the <b>same folder as app.py</b> and restart the Space.
</div>
</div>
"""
# =========================
# 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"""
<div class="result-card fade-in-up">
<div class="result-banner {badge}">
<div class="banner-emoji">{emoji}</div>
<div class="banner-text">
<div class="banner-title">{status}</div>
<div class="banner-sub">Prediction Complete</div>
</div>
<div class="pulse-dot"></div>
</div>
<div class="metrics-grid">
<div class="metric-card glass-effect hover-lift">
<div class="metric-icon">🧬</div>
<div class="metric-label">Gene</div>
<div class="metric-value">{gene_disp}</div>
</div>
<div class="metric-card glass-effect hover-lift">
<div class="metric-icon">πŸ“…</div>
<div class="metric-label">Age Entered</div>
<div class="metric-value">{age_entered}</div>
</div>
<div class="metric-card glass-effect hover-lift">
<div class="metric-icon">πŸ”¬</div>
<div class="metric-label">Model Age</div>
<div class="metric-value">{age_used_disp}</div>
</div>
<div class="metric-card glass-effect hover-lift">
<div class="metric-icon">πŸ“Š</div>
<div class="metric-label">Parse Mode</div>
<div class="metric-value small">{parse_mode.split('(')[0].strip()}</div>
</div>
</div>
<div class="probability-section glass-effect">
<div class="prob-header">
<span class="prob-title">Success Probability</span>
<span class="prob-value-large">{prob_text}</span>
</div>
<div class="prob-bar-container">
<div class="prob-bar-track">
<div class="prob-bar-fill {badge}" style="width:{bar_width};">
<div class="shimmer"></div>
</div>
</div>
<div class="prob-markers">
<span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
</div>
</div>
</div>
<div class="details-grid">
<div class="detail-box glass-effect hover-lift">
<div class="detail-icon {badge}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="detail-content">
<div class="detail-label">Predicted Label</div>
<div class="detail-value">{label}</div>
</div>
</div>
<div class="detail-box glass-effect hover-lift">
<div class="detail-icon {badge}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
</div>
<div class="detail-content">
<div class="detail-label">Speech Score</div>
<div class="detail-value">{speech_disp}</div>
</div>
</div>
</div>
<div class="disclaimer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
</svg>
<span>Informational tool only. Not medical advice. Consult healthcare professionals for clinical decisions.</span>
</div>
</div>
"""
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"""
<div class="batch-results fade-in-up">
<div class="batch-header">
<div class="batch-title">
<span class="batch-icon">πŸ“Š</span>
<span>Batch Analysis Complete</span>
</div>
<div class="batch-count">{n} rows processed</div>
</div>
<div class="stats-showcase">
<div class="stat-mega glass-effect hover-lift">
<div class="stat-mega-icon ok">βœ“</div>
<div class="stat-mega-content">
<div class="stat-mega-label">Predicted Success</div>
<div class="stat-mega-value">{succ}</div>
<div class="stat-mega-sub">{succ_pct}% of total</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card glass-effect hover-lift">
<div class="stat-icon">🎯</div>
<div class="stat-label">Avg Probability</div>
<div class="stat-value">{avg_prob_txt}</div>
</div>
<div class="stat-card glass-effect hover-lift">
<div class="stat-icon">🎀</div>
<div class="stat-label">Avg Speech Score</div>
<div class="stat-value">{avg_speech_txt}</div>
</div>
<div class="stat-card glass-effect hover-lift">
<div class="stat-icon">βš™οΈ</div>
<div class="stat-label">Parse Mode</div>
<div class="stat-value small">{parse_mode.split('(')[0].strip()}</div>
</div>
</div>
</div>
<div class="download-prompt">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
<span>Download the complete results CSV below</span>
</div>
</div>
"""
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"""
<div class='age-preview-box fade-in'>
<div class="preview-icon">✨</div>
<div class="preview-content">
<div class="preview-label">Model will use</div>
<div class="preview-value">{v:.3f} years</div>
</div>
</div>
"""
return """
<div class='age-preview-box fade-in'>
<div class="preview-icon">❌</div>
<div class="preview-content">
<div class="preview-label">Model will use</div>
<div class="preview-value">Invalid</div>
</div>
</div>
"""
# =========================
# 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"""
<div class="app-header">
<div class="header-content">
<div class="header-top">
<div class="header-title">
<div class="header-icon">πŸ”¬</div>
<div class="header-text">
<h1>CI Outcome Predictor</h1>
<div class="subtitle">Advanced ML-powered Cochlear Implant Success Analysis</div>
</div>
</div>
</div>
<div class="header-stats">
<div class="header-stat">
<div class="header-stat-label">Gene Options</div>
<div class="header-stat-value">{len(gene_choices) if APP_READY else 0}</div>
</div>
<div class="header-stat">
<div class="header-stat-label">Model Features</div>
<div class="header-stat-value">{len(input_cols) if APP_READY else 0}</div>
</div>
<div class="header-stat">
<div class="header-stat-label">Prediction Models</div>
<div class="header-stat-value">2</div>
</div>
</div>
</div>
</div>
""")
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('<div class="section-title">πŸ“ Input Parameters</div>')
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('<div class="section-title">πŸ“Š Prediction Results</div>')
single_out = gr.HTML(
value=_missing_files_message() if not APP_READY else
"<div style='text-align:center; padding:60px 20px; color:var(--text-muted);'>πŸ‘ˆ Enter parameters and click 'Run Prediction' to see results</div>"
)
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('<div class="section-title">πŸ“ Batch Processing</div>')
gr.HTML(f"""
<div class="info-box">
<strong>πŸ“‹ Requirements:</strong> Your CSV must include <strong>Gene</strong> and <strong>Age</strong> columns.
Additional features will be auto-filled. Model expects <strong>{len(input_cols) if APP_READY else 0}</strong> total feature columns.
</div>
""")
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('<div class="section-title" style="margin-top:24px;">πŸ‘€ Preview (First 20 Rows)</div>')
batch_preview = gr.Dataframe(headers=None, interactive=False, wrap=True)
gr.HTML('<div class="section-title" style="margin-top:24px;">πŸ’Ύ Download Results</div>')
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)