Spaces:
Runtime error
Runtime error
| """ | |
| app.py | |
| ====== | |
| Mutation Explainability Intelligence System | |
| Gradio Space — explanation-first clinical variant analysis | |
| Three models: | |
| nileshhanotia/mutation-predictor-splice | |
| nileshhanotia/mutation-predictor-v4 | |
| nileshhanotia/mutation-pathogenicity-predictor | |
| Explanation ALWAYS precedes prediction panel. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import json | |
| import logging | |
| import os | |
| import sys | |
| import tempfile | |
| import traceback | |
| import gradio as gr | |
| import numpy as np | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import matplotlib.gridspec as gridspec | |
| from matplotlib.colors import LinearSegmentedColormap | |
| def _fig_to_pil(fig): | |
| """Render matplotlib figure to PIL Image — required for gr.Image in Gradio 4.44.""" | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=110, bbox_inches="tight", | |
| facecolor=fig.get_facecolor()) | |
| buf.seek(0) | |
| from PIL import Image as _PILImage | |
| img = _PILImage.open(buf).copy() | |
| plt.close(fig) | |
| return img | |
| import requests | |
| import time | |
| from functools import lru_cache | |
| # ── project imports ─────────────────────────────────────────────────────────── | |
| from model_loader import ( | |
| ModelRegistry, | |
| encode_for_v2, | |
| find_mutation_pos, | |
| ) | |
| from explainability_engine import ( | |
| extract_splice_signals, | |
| extract_v4_signals, | |
| extract_classic_signals, | |
| compute_cross_model_analysis, | |
| ) | |
| from decision_engine import build_decision, DecisionResult | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", | |
| ) | |
| logger = logging.getLogger("mutation_xai") | |
| # ── Global registry (lazy) ──────────────────────────────────────────────────── | |
| REGISTRY = ModelRegistry(hf_token=os.environ.get("HF_TOKEN")) | |
| # ── Ensembl fetch ───────────────────────────────────────────────────────────── | |
| ENSEMBL_URL = "https://rest.ensembl.org/sequence/region/human" | |
| WINDOW_HALF = 49 # 49 + 1 + 49 = 99 bp (matches all three models) | |
| def _fetch_ensembl(chrom: str, start: int, end: int) -> str: | |
| chrom = chrom.lstrip("chrCHR").strip() | |
| region = f"{chrom}:{start}..{end}:1" | |
| url = f"{ENSEMBL_URL}/{region}" | |
| for attempt in range(3): | |
| try: | |
| r = requests.get(url, params={"content-type": "application/json"}, timeout=15) | |
| if r.status_code == 429: | |
| time.sleep(int(r.headers.get("Retry-After", 5))) | |
| continue | |
| r.raise_for_status() | |
| data = r.json() | |
| if isinstance(data, list): data = data[0] | |
| return data.get("seq", "").upper() | |
| except Exception as e: | |
| if attempt == 2: | |
| raise RuntimeError(f"Ensembl API failed: {e}") | |
| time.sleep(1.5 * (2 ** attempt)) | |
| return "" | |
| def fetch_window(chrom: str, pos: int) -> tuple[str, str, int]: | |
| """ | |
| Returns (ref_seq_99bp, mut_seq_placeholder, mutation_pos_in_window). | |
| Caller must insert the alt base into mut_seq at mutation_pos. | |
| """ | |
| chrom_clean = chrom.lstrip("chrCHR").strip() | |
| start = max(1, pos - WINDOW_HALF) | |
| end = pos + WINDOW_HALF | |
| seq = _fetch_ensembl(chrom_clean, start, end) | |
| if len(seq) < 1: | |
| raise ValueError(f"Empty sequence returned for chr{chrom}:{start}-{end}") | |
| # Pad/trim to 99 | |
| seq = (seq + "N" * 99)[:99] | |
| mut_pos = pos - start # 0-indexed position within window | |
| mut_pos = max(0, min(98, mut_pos)) | |
| return seq, mut_pos | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Visualisation helpers | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| _BG = "#0D1117" | |
| _TEXT = "#E6EDF3" | |
| _MUTED = "#7D8590" | |
| _BLUE = "#58A6FF" | |
| _GREEN = "#3FB950" | |
| _RED = "#F85149" | |
| _ORG = "#D29922" | |
| _CMAP_ACTIVATION = LinearSegmentedColormap.from_list( | |
| "act", [(0.04,0.22,0.47),(0.96,0.96,0.96),(0.72,0.05,0.12)], N=256) | |
| _CMAP_SPLICE = LinearSegmentedColormap.from_list( | |
| "splice", [(0.0,"#f7f7f7"),(0.3,"#fee08b"),(0.6,"#fc8d59"),(1.0,"#d73027")]) | |
| def _fig_base(w=15, h=2.8): | |
| fig, ax = plt.subplots(figsize=(w, h), facecolor=_BG) | |
| ax.set_facecolor(_BG) | |
| return fig, ax | |
| def _style_ax(ax, title): | |
| ax.set_title(title, color=_TEXT, fontsize=9, loc="left", pad=4, fontweight="bold") | |
| for sp in ["top","right"]: | |
| ax.spines[sp].set_visible(False) | |
| ax.spines["left"].set_color("#333") | |
| ax.spines["bottom"].set_color("#333") | |
| ax.tick_params(colors=_TEXT, labelsize=7) | |
| def plot_activation_heatmap(profile: np.ndarray, mutation_pos: int, | |
| label: str, prob: float): | |
| imp = profile.copy() | |
| if imp.max() > 0: | |
| imp /= imp.max() | |
| fig, ax = _fig_base(15, 2.5) | |
| im = ax.imshow(imp[np.newaxis,:], aspect="auto", cmap=_CMAP_ACTIVATION, | |
| vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1]) | |
| if mutation_pos >= 0: | |
| ax.axvline(x=mutation_pos, color=_GREEN, linewidth=2.0, linestyle="--", | |
| label=f"Mutation pos {mutation_pos}") | |
| ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6, | |
| loc="upper right") | |
| cb = fig.colorbar(im, ax=ax, pad=0.01) | |
| cb.set_label("Activation intensity", color=_TEXT, fontsize=8) | |
| cb.ax.tick_params(colors=_TEXT, labelsize=7) | |
| ax.set_xlabel("Nucleotide position (99 bp window)", color=_TEXT, fontsize=9) | |
| ax.set_xticks(range(0, 99, 10)) | |
| ax.set_yticks([]) | |
| _style_ax(ax, f"CNN conv3 Activation — {label} (prob={prob:.4f})") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| def plot_splice_heatmap(ref_seq: str, mutation_pos: int): | |
| seq = (ref_seq.upper() + "N" * 99)[:99] | |
| scores = np.zeros(99) | |
| donors, acceptors = [], [] | |
| for i in range(len(seq)-1): | |
| if seq[i:i+2] == "GT": donors.append(i) | |
| if seq[i:i+2] == "AG": acceptors.append(i) | |
| for p in donors: | |
| for d in range(-8,9): | |
| if 0 <= p+d < 99: scores[p+d] = max(scores[p+d], 0.5) | |
| for p in acceptors: | |
| for d in range(-8,9): | |
| if 0 <= p+d < 99: scores[p+d] = max(scores[p+d], 0.5) | |
| for p in donors: | |
| if 0 <= p < 99: scores[p] = 1.0 | |
| for p in acceptors: | |
| if 0 <= p < 99: scores[p] = max(scores[p], 0.8) | |
| fig, ax = _fig_base(15, 2.5) | |
| im = ax.imshow(scores[np.newaxis,:], aspect="auto", cmap=_CMAP_SPLICE, | |
| vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1]) | |
| if mutation_pos >= 0: | |
| ax.axvline(x=mutation_pos, color=_BLUE, linewidth=2.0, linestyle="--", | |
| label=f"Mutation pos {mutation_pos}") | |
| ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6, | |
| loc="upper right") | |
| cb = fig.colorbar(im, ax=ax, pad=0.01) | |
| cb.set_label("Splice risk", color=_TEXT, fontsize=8) | |
| cb.ax.tick_params(colors=_TEXT, labelsize=7) | |
| ax.set_xlabel("Nucleotide position (99 bp window)", color=_TEXT, fontsize=9) | |
| ax.set_xticks(range(0, 99, 10)) | |
| ax.set_yticks([]) | |
| _style_ax(ax, "Splice Distance Risk — GT donor / AG acceptor signals") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| def plot_gradient_heatmap(attr: np.ndarray, mutation_pos: int, label: str): | |
| fig, ax = _fig_base(15, 2.5) | |
| im = ax.imshow(attr[np.newaxis,:], aspect="auto", cmap="PuOr", | |
| vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1]) | |
| if mutation_pos >= 0: | |
| ax.axvline(x=mutation_pos, color=_GREEN, linewidth=2.0, linestyle="--", | |
| label=f"Mutation pos {mutation_pos}") | |
| ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6, | |
| loc="upper right") | |
| cb = fig.colorbar(im, ax=ax, pad=0.01) | |
| cb.set_label("Gradient attribution", color=_TEXT, fontsize=8) | |
| cb.ax.tick_params(colors=_TEXT, labelsize=7) | |
| ax.set_xlabel("Nucleotide position", color=_TEXT, fontsize=9) | |
| ax.set_xticks(range(0, 99, 10)) | |
| ax.set_yticks([]) | |
| _style_ax(ax, f"Gradient Attribution Map — {label}") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| def plot_counterfactual(cf_table: list[dict], orig_prob: float, cf_delta: float): | |
| if not cf_table: | |
| fig, ax = plt.subplots(figsize=(8, 3), facecolor=_BG) | |
| ax.text(0.5, 0.5, "No counterfactual data", ha="center", va="center", | |
| color=_TEXT, fontsize=12) | |
| ax.axis("off") | |
| return _fig_to_pil(fig) | |
| labels = [r["mutation"] for r in cf_table] | |
| probs = [r["probability"] for r in cf_table] | |
| max_p, min_p = max(probs), min(probs) | |
| colors = [_RED if p == max_p else (_BLUE if p == min_p else "#74add1") for p in probs] | |
| fig, ax = plt.subplots(figsize=(10, 3.5), facecolor=_BG) | |
| ax.set_facecolor(_BG) | |
| bars = ax.bar(labels, probs, color=colors, edgecolor="#444", linewidth=0.7) | |
| ax.axhline(0.5, color=_MUTED, linestyle="--", linewidth=1.0, label="Decision boundary (0.5)") | |
| ax.axhline(orig_prob, color=_ORG, linestyle="-.", linewidth=1.5, | |
| label=f"Original mutation ({orig_prob:.3f})") | |
| ax.set_ylim(0, 1.05) | |
| ax.set_xlabel("Alternative mutation", color=_TEXT, fontsize=10) | |
| ax.set_ylabel("Pathogenicity probability", color=_TEXT, fontsize=10) | |
| ax.tick_params(colors=_TEXT) | |
| ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.5) | |
| for b, p in zip(bars, probs): | |
| ax.text(b.get_x() + b.get_width()/2, b.get_height()+0.01, | |
| f"{p:.3f}", ha="center", va="bottom", fontsize=8, color=_TEXT) | |
| for sp in ["top","right"]: | |
| ax.spines[sp].set_visible(False) | |
| ax.spines["left"].set_color("#333") | |
| ax.spines["bottom"].set_color("#333") | |
| ax.set_title( | |
| f"Counterfactual Analysis | Δ={cf_delta:.4f} | " | |
| f"range {min_p:.3f}–{max_p:.3f}", | |
| color=_TEXT, fontsize=10, loc="left") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| def plot_ablation(ablation: dict): | |
| labels = [ | |
| "Splice features\n(donor/acceptor/region)", | |
| "Region features\n(exon/intron flags)", | |
| "Mutation type\n(one-hot)", | |
| ] | |
| deltas = [ablation["splice_causal_effect"], | |
| ablation["region_causal_effect"], | |
| ablation["mutation_causal_effect"]] | |
| pcts = [ablation["splice_pct"], ablation["region_pct"], ablation["mutation_pct"]] | |
| colors = [_RED, _ORG, _BLUE] | |
| fig, ax = plt.subplots(figsize=(9, 3.0), facecolor=_BG) | |
| ax.set_facecolor(_BG) | |
| bars = ax.barh(labels, deltas, color=colors, edgecolor="#444", linewidth=0.6) | |
| ax.set_xlabel("Probability delta when ablated (causal effect)", color=_TEXT, fontsize=9) | |
| ax.tick_params(colors=_TEXT, labelsize=8) | |
| ax.set_title( | |
| f"Feature Ablation | baseline prob={ablation['baseline_probability']:.4f}", | |
| color=_TEXT, fontsize=10, loc="left") | |
| for b, d, p in zip(bars, deltas, pcts): | |
| ax.text(b.get_width()+0.002, b.get_y()+b.get_height()/2, | |
| f" Δ{d:.4f} ({p}%)", va="center", fontsize=9, color=_TEXT) | |
| ax.set_xlim(0, max(deltas+[0.01]) * 1.6) | |
| for sp in ["top","right"]: | |
| ax.spines[sp].set_visible(False) | |
| ax.spines["left"].set_color("#333") | |
| ax.spines["bottom"].set_color("#333") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| def plot_xai_metrics(xai): | |
| """Radar-style bar chart of explainability metrics.""" | |
| labels = ["Model\nAgreement", "XAI\nStrength", "CF\nMagnitude", | |
| "Locality\nScore", "Concentration\nIndex"] | |
| values = [ | |
| xai.model_agreement, | |
| xai.explainability_strength, | |
| min(xai.counterfactual_magnitude / 0.4, 1.0), | |
| xai.cross_model_locality_score, | |
| xai.signal_concentration_index, | |
| ] | |
| colors = [_GREEN if v >= 0.65 else (_ORG if v >= 0.40 else _RED) for v in values] | |
| fig, ax = plt.subplots(figsize=(10, 3.0), facecolor=_BG) | |
| ax.set_facecolor(_BG) | |
| bars = ax.bar(labels, values, color=colors, edgecolor="#444", linewidth=0.6, width=0.5) | |
| ax.axhline(0.65, color=_GREEN, linestyle="--", linewidth=0.8, alpha=0.6, label="High (≥0.65)") | |
| ax.axhline(0.40, color=_ORG, linestyle="--", linewidth=0.8, alpha=0.6, label="Moderate (≥0.40)") | |
| ax.set_ylim(0, 1.1) | |
| ax.set_ylabel("Score (0–1)", color=_TEXT, fontsize=9) | |
| ax.tick_params(colors=_TEXT, labelsize=8) | |
| ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.4, loc="upper right") | |
| for b, v in zip(bars, values): | |
| ax.text(b.get_x()+b.get_width()/2, b.get_height()+0.02, | |
| f"{v:.3f}", ha="center", fontsize=9, color=_TEXT) | |
| for sp in ["top","right"]: | |
| ax.spines[sp].set_visible(False) | |
| ax.spines["left"].set_color("#333") | |
| ax.spines["bottom"].set_color("#333") | |
| ax.set_title("Explainability Metrics Panel", color=_TEXT, fontsize=10, loc="left") | |
| fig.tight_layout() | |
| return _fig_to_pil(fig) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Core pipeline | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def run_pipeline( | |
| chrom: str, | |
| position: str, | |
| ref_base: str, | |
| alt_base: str, | |
| exon_flag: int, | |
| intron_flag: int, | |
| ): | |
| """Main Gradio callback. Returns all outputs.""" | |
| chrom = chrom.strip() | |
| ref_base = ref_base.strip().upper() | |
| alt_base = alt_base.strip().upper() | |
| try: | |
| pos = int(position.strip().replace(",","")) | |
| except ValueError: | |
| return _error(f"Invalid position: '{position}'") | |
| for b, name in [(ref_base,"Reference"),(alt_base,"Alternate")]: | |
| if b not in "ACGT" or len(b) != 1: | |
| return _error(f"{name} base must be A, C, G, or T. Got: '{b}'") | |
| if ref_base == alt_base: | |
| return _error("Reference and alternate bases are identical.") | |
| try: | |
| ref_seq, mutation_pos = fetch_window(chrom, pos) | |
| # Validate reference base | |
| actual_ref = ref_seq[mutation_pos].upper() | |
| if actual_ref != ref_base: | |
| return _error( | |
| f"Reference mismatch at chr{chrom}:{pos}: " | |
| f"genome has '{actual_ref}', you entered '{ref_base}'." | |
| ) | |
| # Build mutated sequence | |
| mut_seq = ref_seq[:mutation_pos] + alt_base + ref_seq[mutation_pos+1:] | |
| splice_sig = extract_splice_signals( | |
| REGISTRY.splice, ref_seq, mut_seq, exon_flag, intron_flag) | |
| v4_sig = extract_v4_signals( | |
| REGISTRY.v4, ref_seq, mut_seq, exon_flag, intron_flag) | |
| classic_sig = extract_classic_signals( | |
| REGISTRY.classic, ref_seq, mut_seq) | |
| xai = compute_cross_model_analysis(splice_sig, v4_sig, classic_sig, mutation_pos) | |
| result = build_decision( | |
| chrom=chrom, pos=pos, ref=ref_base, alt=alt_base, | |
| ref_seq=ref_seq, mut_seq=mut_seq, mutation_pos=mutation_pos, | |
| splice=splice_sig, v4=v4_sig, classic=classic_sig, xai=xai, | |
| ) | |
| plots = _build_all_plots(result) | |
| json_str = result.to_json() | |
| json_file = _write_json_file(json_str) | |
| demo_banner = ( | |
| "\n> ⚠️ **DEMO MODE** — models are running with random weights. " | |
| "Place real checkpoints or ensure HF_TOKEN is set.\n" | |
| if REGISTRY.demo_mode else "" | |
| ) | |
| summary_md = _build_summary_md(result, demo_banner) | |
| return ( | |
| summary_md, # 0: explanation summary (FIRST) | |
| result.final_explanation, # 1: final explanation text | |
| plots["xai_metrics"], # 2: XAI metrics panel | |
| plots["splice_activation"], # 3: splice conv3 heatmap | |
| plots["splice_heatmap"], # 4: splice distance heatmap | |
| plots["v4_activation"], # 5: v4 conv3 heatmap | |
| plots["classic_activation"], # 6: classic conv3 heatmap | |
| plots["v4_gradient"], # 7: v4 gradient attribution | |
| plots["splice_gradient"], # 8: splice gradient attribution | |
| plots["counterfactual"], # 9: counterfactual chart | |
| plots["ablation"], # 10: ablation chart | |
| json_str, # 11: JSON report | |
| json_file, # 12: download file | |
| ) | |
| except Exception as exc: | |
| logger.error("Pipeline error: %s\n%s", exc, traceback.format_exc()) | |
| return _error(f"Error: {exc}\n\n```\n{traceback.format_exc()}\n```") | |
| def _build_all_plots(r: DecisionResult) -> dict: | |
| mp = r.mutation_pos | |
| return { | |
| "xai_metrics": plot_xai_metrics(r.xai), | |
| "splice_activation": plot_activation_heatmap( | |
| r.splice.conv3_profile, mp, "Splice Model", r.splice.probability), | |
| "splice_heatmap": plot_splice_heatmap(r.ref_seq, mp), | |
| "v4_activation": plot_activation_heatmap( | |
| r.v4.conv3_profile, mp, "V4 Model", r.v4.probability), | |
| "classic_activation": plot_activation_heatmap( | |
| r.classic.conv3_profile, mp, "Classic Model", r.classic.probability), | |
| "v4_gradient": plot_gradient_heatmap( | |
| r.v4.gradient_attribution, mp, "V4 Model"), | |
| "splice_gradient": plot_gradient_heatmap( | |
| r.splice.gradient_attribution, mp, "Splice Model"), | |
| "counterfactual": plot_counterfactual( | |
| r.splice.counterfactual_table, | |
| r.splice.probability, | |
| r.splice.counterfactual_delta), | |
| "ablation": plot_ablation(r.splice.ablation), | |
| } | |
| def _write_json_file(json_str: str) -> str: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False, | |
| mode="w", encoding="utf-8") | |
| tmp.write(json_str) | |
| tmp.close() | |
| return tmp.name | |
| def _build_summary_md(r: DecisionResult, demo_banner: str) -> str: | |
| mech_icon = { | |
| "Splice-driven": "🔀", | |
| "Protein-driven": "🧬", | |
| "Consensus": "✅", | |
| "Ambiguous": "⚠️", | |
| }.get(r.dominant_mechanism, "❓") | |
| tier_icon = { | |
| "PATHOGENIC": "🔴", | |
| "LIKELY PATHOGENIC": "🟠", | |
| "POSSIBLY PATHOGENIC": "🟡", | |
| "LIKELY BENIGN": "🟢", | |
| "BENIGN": "🟢", | |
| }.get(r.risk_tier, "⚪") | |
| conf_icon = {"High": "🔵", "Moderate": "🟡", "Low": "🔴"}.get(r.confidence, "⚪") | |
| return f"""{demo_banner} | |
| ## {tier_icon} Risk Tier: **{r.risk_tier}** | |
| | Field | Value | | |
| |---|---| | |
| | **Variant** | `chr{r.chrom}:g.{r.pos}{r.ref}>{r.alt}` | | |
| | **Unified Probability** | `{r.unified_probability:.4f}` | | |
| | **Dominant Mechanism** | {mech_icon} {r.dominant_mechanism} | | |
| | **Confidence** | {conf_icon} {r.confidence} | | |
| | **Splice Model** | `{r.splice.probability:.4f}` — {r.splice.risk_tier} | | |
| | **V4 Model** | `{r.v4.probability:.4f}` | | |
| | **Classic Model** | `{r.classic.probability:.4f}` | | |
| --- | |
| ### Explainability Metrics | |
| | Metric | Value | | |
| |---|---| | |
| | **Mutation Peak Ratio** | `{r.xai.mutation_peak_ratio:.4f}` | | |
| | **Counterfactual Magnitude** | `{r.xai.counterfactual_magnitude:.4f}` | | |
| | **Cross-Model Locality** | `{r.xai.cross_model_locality_score:.4f}` | | |
| | **Signal Concentration** | `{r.xai.signal_concentration_index:.4f}` | | |
| | **XAI Strength Score** | `{r.xai.explainability_strength:.4f}` | | |
| | **Activation Pattern** | `{r.xai.activation_pattern_type}` | | |
| | **Model Agreement** | `{r.xai.model_agreement:.4f}` | | |
| --- | |
| ### Interpretation Briefs | |
| **Splice:** {r.splice_analysis[:300]}{'…' if len(r.splice_analysis)>300 else ''} | |
| **Protein:** {r.protein_analysis[:250]}{'…' if len(r.protein_analysis)>250 else ''} | |
| **Agreement:** {r.agreement_analysis[:250]}{'…' if len(r.agreement_analysis)>250 else ''} | |
| """ | |
| def _error(msg: str): | |
| empties = [None] * 9 | |
| return ( | |
| f"❌ **Error**\n\n{msg}", | |
| "", empty, empty, empty, empty, empty, | |
| empty, empty, empty, empty, | |
| "{}", None, | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Gradio UI | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;400;600;700&display=swap'); | |
| :root { | |
| --bg: #0D1117; --surface: #161B22; --border: #30363D; | |
| --text: #E6EDF3; --muted: #7D8590; | |
| --blue: #58A6FF; --green: #3FB950; --red: #F85149; --orange: #D29922; | |
| --font: 'Inter', system-ui; --mono: 'JetBrains Mono', monospace; | |
| } | |
| body, .gradio-container { background: var(--bg) !important; color: var(--text) !important; font-family: var(--font) !important; } | |
| .xai-header { background: linear-gradient(135deg, #0D1117 0%, #161B22 60%, #1a2332 100%); | |
| border-bottom: 1px solid var(--border); padding: 2rem 2.5rem 1.5rem; margin-bottom: 1.5rem; } | |
| .xai-header h1 { font-size: 1.7rem; font-weight: 700; letter-spacing: -0.03em; margin: 0 0 0.3rem; } | |
| .xai-header h1 em { color: var(--blue); font-style: normal; } | |
| .xai-header p { color: var(--muted); font-size: 0.82rem; margin: 0; } | |
| .section-title { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.12em; | |
| text-transform: uppercase; color: var(--muted); border-bottom: 1px solid var(--border); | |
| padding-bottom: 0.4rem; margin-bottom: 1rem; } | |
| .gradio-textbox input, .gradio-textbox textarea, .gradio-number input { | |
| background: #161B22 !important; border: 1px solid var(--border) !important; | |
| color: var(--text) !important; border-radius: 6px !important; | |
| font-family: var(--mono) !important; font-size: 0.88rem !important; } | |
| label span { color: var(--muted) !important; font-size: 0.76rem !important; font-weight: 500 !important; } | |
| .run-btn { background: linear-gradient(135deg, #1f6feb 0%, #388bfd 100%) !important; | |
| border: none !important; color: white !important; font-weight: 700 !important; | |
| font-size: 0.92rem !important; border-radius: 6px !important; letter-spacing: 0.04em !important; } | |
| .run-btn:hover { transform: translateY(-1px) !important; box-shadow: 0 4px 14px rgba(88,166,255,.35) !important; } | |
| .explanation-first { border: 1px solid var(--blue) !important; | |
| border-radius: 8px !important; background: rgba(88,166,255,0.04) !important; padding: 1rem !important; } | |
| .gradio-markdown table { border-collapse: collapse; width: 100%; font-size: 0.83rem; } | |
| .gradio-markdown th { background: #161B22; color: var(--muted); font-size: 0.68rem; | |
| letter-spacing: 0.08em; text-transform: uppercase; padding: 0.45rem 0.7rem; | |
| border: 1px solid var(--border); } | |
| .gradio-markdown td { padding: 0.42rem 0.7rem; border: 1px solid var(--border); | |
| font-family: var(--mono); font-size: 0.80rem; } | |
| .gradio-markdown code { background: #161B22; padding: 1px 5px; border-radius: 3px; | |
| font-family: var(--mono); color: var(--blue); font-size: 0.85em; } | |
| .gradio-image img { border-radius: 6px; border: 1px solid var(--border); } | |
| .gradio-tabs button { font-size: 0.80rem !important; color: var(--muted) !important; | |
| border-bottom: 2px solid transparent !important; background: transparent !important; } | |
| .gradio-tabs button[aria-selected=true] { color: var(--blue) !important; | |
| border-bottom-color: var(--blue) !important; } | |
| .gradio-textbox textarea { font-family: var(--mono) !important; font-size: 0.76rem !important; line-height: 1.5 !important; } | |
| """ | |
| HEADER_HTML = """ | |
| <div class="xai-header"> | |
| <h1>Mutation <em>Explainability</em> Intelligence System</h1> | |
| <p> | |
| Three-model ensemble · | |
| Explanation before prediction · | |
| conv3 activations · gradient attribution · counterfactual analysis · | |
| feature ablation · splice distance · cross-model locality | |
| </p> | |
| </div> | |
| """ | |
| EXAMPLES = [ | |
| ["17", "43071077", "G", "A", 1, 0], # BRCA1 region | |
| ["11", "5226929", "T", "C", 1, 0], # HBB region | |
| ["7", "117548628","T", "A", 1, 0], # CFTR region | |
| ["3", "37053577", "A", "C", 0, 1], # intronic | |
| ["19", "44908684", "G", "T", 1, 0], # APOE region | |
| ] | |
| def build_ui() -> gr.Blocks: | |
| with gr.Blocks(title="Mutation Explainability Intelligence System", | |
| css=CSS) as demo: | |
| gr.HTML(HEADER_HTML) | |
| with gr.Row(equal_height=False): | |
| # ─── INPUT PANEL ────────────────────────────────────────── | |
| with gr.Column(scale=1, min_width=280): | |
| gr.HTML('<div class="section-title">Variant Input</div>') | |
| chrom_in = gr.Textbox(label="Chromosome", value="17", max_lines=1) | |
| pos_in = gr.Textbox(label="Position (hg38, 1-based)", value="43071077", max_lines=1) | |
| with gr.Row(): | |
| ref_in = gr.Textbox(label="Ref Base", value="G", max_lines=1) | |
| alt_in = gr.Textbox(label="Alt Base", value="A", max_lines=1) | |
| with gr.Row(): | |
| exon_in = gr.Radio([0,1], label="Exon flag", value=1) | |
| intron_in = gr.Radio([0,1], label="Intron flag", value=0) | |
| run_btn = gr.Button("▶ Analyse Variant", variant="primary", | |
| elem_classes="run-btn") | |
| gr.HTML('<div class="section-title" style="margin-top:1rem">Examples</div>') | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| inputs=[chrom_in, pos_in, ref_in, alt_in, exon_in, intron_in], | |
| label="", | |
| examples_per_page=5, | |
| ) | |
| # ─── OUTPUT PANEL ───────────────────────────────────────── | |
| with gr.Column(scale=3, min_width=640): | |
| # ══════════════════════════════════════════════════════ | |
| # EXPLANATION FIRST — always shown before prediction | |
| # ══════════════════════════════════════════════════════ | |
| gr.HTML('<div class="section-title">① Explanation & Signal Analysis</div>') | |
| summary_out = gr.Markdown( | |
| value="*Run an analysis to see the full explanation.*", | |
| elem_classes="explanation-first", | |
| ) | |
| final_exp_out = gr.Textbox( | |
| label="Final Explanation (grounded in internal signals)", | |
| lines=10, max_lines=18, show_copy_button=True, | |
| ) | |
| gr.HTML('<div class="section-title" style="margin-top:1.5rem">② Explainability Metrics Panel</div>') | |
| xai_metrics_plot = gr.Image(label="Explainability Metrics") | |
| # ── Tabs for visualisations ──────────────────────────── | |
| gr.HTML('<div class="section-title" style="margin-top:1.5rem">③ Internal Model Signals</div>') | |
| with gr.Tabs(): | |
| with gr.TabItem("🔬 Splice Model"): | |
| splice_act_plot = gr.Image(label="conv3 Activation Heatmap — Splice") | |
| splice_dist_plot = gr.Image(label="Splice Distance Risk Heatmap") | |
| splice_grad_plot = gr.Image(label="Gradient Attribution — Splice") | |
| with gr.TabItem("🧬 V4 Model"): | |
| v4_act_plot = gr.Image(label="conv3 Activation Heatmap — V4") | |
| v4_grad_plot = gr.Image(label="Gradient Attribution — V4") | |
| with gr.TabItem("📊 Classic Model"): | |
| classic_act_plot = gr.Image(label="conv3 Activation Heatmap — Classic") | |
| with gr.TabItem("⚗️ Causal Analysis"): | |
| cf_plot = gr.Image(label="Counterfactual Mutation Analysis") | |
| abl_plot = gr.Image(label="Feature Ablation Causal Chart") | |
| with gr.TabItem("📋 JSON Report"): | |
| json_out = gr.Textbox(label="Structured JSON Report", | |
| lines=30, max_lines=60, | |
| show_copy_button=True) | |
| dl_btn = gr.File(label="⬇ Download JSON Report") | |
| # ── Wire outputs ────────────────────────────────────────────── | |
| all_outputs = [ | |
| summary_out, final_exp_out, | |
| xai_metrics_plot, | |
| splice_act_plot, splice_dist_plot, v4_act_plot, | |
| classic_act_plot, v4_grad_plot, splice_grad_plot, | |
| cf_plot, abl_plot, | |
| json_out, dl_btn, | |
| ] | |
| run_btn.click( | |
| fn=run_pipeline, | |
| inputs=[chrom_in, pos_in, ref_in, alt_in, exon_in, intron_in], | |
| outputs=all_outputs, | |
| show_progress=True, | |
| ) | |
| gr.HTML(""" | |
| <div style="text-align:center; color:#7D8590; font-size:0.70rem; | |
| padding:1rem; margin-top:1rem; border-top:1px solid #30363D;"> | |
| Mutation Explainability Intelligence System | |
| · Models: nileshhanotia/{mutation-predictor-splice, mutation-predictor-v4, mutation-pathogenicity-predictor} | |
| · For Research Use Only · Not for Clinical Diagnosis | |
| </div> | |
| """) | |
| return demo | |
| demo = build_ui() | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| share=False, | |
| ) | |