nileshhanotia commited on
Commit
1245dad
·
verified ·
1 Parent(s): d181d45

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +345 -719
app.py CHANGED
@@ -2,12 +2,14 @@
2
  app.py
3
  ======
4
  Mutation Explainability Intelligence System
5
- Gradio Space — explanation ALWAYS precedes the prediction panel.
6
 
7
  Three models:
8
  nileshhanotia/mutation-predictor-splice
9
  nileshhanotia/mutation-predictor-v4
10
  nileshhanotia/mutation-pathogenicity-predictor
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -15,10 +17,9 @@ import io
15
  import json
16
  import logging
17
  import os
 
18
  import tempfile
19
- import time
20
  import traceback
21
- from functools import lru_cache
22
 
23
  import gradio as gr
24
  import numpy as np
@@ -27,16 +28,34 @@ matplotlib.use("Agg")
27
  import matplotlib.pyplot as plt
28
  import matplotlib.gridspec as gridspec
29
  from matplotlib.colors import LinearSegmentedColormap
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  import requests
 
 
31
 
32
- from model_loader import ModelRegistry, encode_for_v2, find_mutation_pos
 
 
 
 
 
33
  from explainability_engine import (
34
  extract_splice_signals,
35
  extract_v4_signals,
36
  extract_classic_signals,
37
  compute_cross_model_analysis,
38
- V4Signals,
39
- ClassicSignals,
40
  )
41
  from decision_engine import build_decision, DecisionResult
42
 
@@ -46,83 +65,59 @@ logging.basicConfig(
46
  )
47
  logger = logging.getLogger("mutation_xai")
48
 
49
-
50
- # ═══════════════════════════════════════════════════════════════════════════════
51
- # Model registry — loaded once at startup
52
- # ═══════════════════════════════════════════════════════════════════════════════
53
-
54
  REGISTRY = ModelRegistry(hf_token=os.environ.get("HF_TOKEN"))
55
 
56
-
57
- # ═══════════════════════════════════════════════════════════════════════════════
58
- # Ensembl sequence fetch
59
- # ═══════════════════════════════════════════════════════════════════════════════
60
-
61
  ENSEMBL_URL = "https://rest.ensembl.org/sequence/region/human"
62
- WINDOW_HALF = 49 # 49 + 1 + 49 = 99 bp
63
 
64
 
65
- @lru_cache(maxsize=512)
66
  def _fetch_ensembl(chrom: str, start: int, end: int) -> str:
67
- chrom = chrom.lstrip("chrCHR").strip()
68
  region = f"{chrom}:{start}..{end}:1"
69
  url = f"{ENSEMBL_URL}/{region}"
70
  for attempt in range(3):
71
  try:
72
- r = requests.get(url,
73
- params={"content-type": "application/json"},
74
- timeout=15)
75
  if r.status_code == 429:
76
- wait = int(r.headers.get("Retry-After", 5))
77
- logger.warning(f"Ensembl rate-limited — waiting {wait}s")
78
- time.sleep(wait)
79
  continue
80
  r.raise_for_status()
81
  data = r.json()
82
- if isinstance(data, list):
83
- data = data[0]
84
  return data.get("seq", "").upper()
85
- except Exception as exc:
86
  if attempt == 2:
87
- raise RuntimeError(
88
- f"Ensembl API failed after 3 attempts: {exc}")
89
  time.sleep(1.5 * (2 ** attempt))
90
  return ""
91
 
92
 
93
- def fetch_window(chrom: str, pos: int, ref: str, alt: str):
94
- """Fetch 99-bp window. Returns (ref_seq, mut_seq, mut_pos_in_window)."""
95
- chrom_clean = chrom.strip().lstrip("chrCHR")
96
- start = max(1, pos - WINDOW_HALF)
97
- end = pos + WINDOW_HALF
98
- raw = _fetch_ensembl(chrom_clean, start, end)
99
-
100
- if not raw:
101
- raise ValueError(
102
- f"Empty sequence from Ensembl for chr{chrom}:{start}-{end}")
103
-
104
- seq = (raw + "N" * 99)[:99]
105
- mut_pos = max(0, min(98, pos - start))
106
-
107
- genome_ref = seq[mut_pos] if mut_pos < len(seq) else "N"
108
- if genome_ref.upper() != ref.upper():
109
- logger.warning(
110
- f"Reference mismatch at chr{chrom}:{pos}: "
111
- f"Ensembl={genome_ref}, user={ref}. Using Ensembl sequence.")
112
-
113
- mut_list = list(seq)
114
- mut_list[mut_pos] = alt.upper()
115
- mut_seq = "".join(mut_list)
116
-
117
- return seq, mut_seq, mut_pos
118
 
119
 
120
  # ═══════════════════════════════════════════════════════════════════════════════
121
- # Colour palette & colour maps
122
  # ═══════════════════════════════════════════════════════════════════════════════
123
 
124
  _BG = "#0D1117"
125
- _SURF = "#161B22"
126
  _TEXT = "#E6EDF3"
127
  _MUTED = "#7D8590"
128
  _BLUE = "#58A6FF"
@@ -130,554 +125,401 @@ _GREEN = "#3FB950"
130
  _RED = "#F85149"
131
  _ORG = "#D29922"
132
 
133
- _CMAP_ACT = LinearSegmentedColormap.from_list(
134
- "act",
135
- [(0.04, 0.22, 0.47), (0.96, 0.96, 0.96), (0.72, 0.05, 0.12)],
136
- N=256)
137
  _CMAP_SPLICE = LinearSegmentedColormap.from_list(
138
- "splice",
139
- [(0, "#f7f7f7"), (0.3, "#fee08b"), (0.6, "#fc8d59"), (1, "#d73027")])
140
- _CMAP_GRAD = matplotlib.colormaps.get_cmap("PuOr")
141
 
142
 
143
- # ═══════════════════════════════════════════════════════════════════════════════
144
- # Visualisation helpers
145
- # ═══════════════════════════════════════════════════════════════════════════════
146
-
147
- def _pil(fig):
148
- """Render matplotlib figure to PIL Image (required for gr.Image)."""
149
- buf = io.BytesIO()
150
- fig.savefig(buf, format="png", dpi=110, bbox_inches="tight",
151
- facecolor=fig.get_facecolor())
152
- buf.seek(0)
153
- from PIL import Image
154
- img = Image.open(buf).copy()
155
- plt.close(fig)
156
- return img
157
-
158
-
159
- def _empty_pil():
160
- fig, ax = plt.subplots(figsize=(4, 2), facecolor=_BG)
161
  ax.set_facecolor(_BG)
162
- ax.axis("off")
163
- return _pil(fig)
164
 
165
 
166
- def _style_ax(ax, title=""):
167
- ax.set_title(title, color=_TEXT, fontsize=9, loc="left",
168
- pad=4, fontweight="bold")
169
- for sp in ["top", "right"]:
170
  ax.spines[sp].set_visible(False)
171
  ax.spines["left"].set_color("#333")
172
  ax.spines["bottom"].set_color("#333")
173
  ax.tick_params(colors=_TEXT, labelsize=7)
174
 
175
 
176
- def _heatmap_pil(profile: np.ndarray, mutation_pos: int,
177
- cmap, label: str, ylabel: str,
178
- prob: float | None = None):
179
  imp = profile.copy()
180
  if imp.max() > 0:
181
  imp /= imp.max()
182
- fig, ax = plt.subplots(figsize=(15, 2.5), facecolor=_BG)
183
- ax.set_facecolor(_BG)
184
- im = ax.imshow(imp[np.newaxis, :], aspect="auto", cmap=cmap,
185
  vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1])
186
  if mutation_pos >= 0:
187
- ax.axvline(x=mutation_pos, color=_GREEN, linewidth=2.0,
188
- linestyle="--", label=f"Mutation pos {mutation_pos}")
189
- ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT,
190
- framealpha=0.6, loc="upper right")
191
  cb = fig.colorbar(im, ax=ax, pad=0.01)
192
- cb.set_label(ylabel, color=_TEXT, fontsize=8)
193
  cb.ax.tick_params(colors=_TEXT, labelsize=7)
194
- ax.set_xlabel("Nucleotide position (99-bp window)",
195
- color=_TEXT, fontsize=9)
196
  ax.set_xticks(range(0, 99, 10))
197
  ax.set_yticks([])
198
- title = label + (f" (prob={prob:.4f})" if prob is not None else "")
199
- _style_ax(ax, title)
200
  fig.tight_layout()
201
- return _pil(fig)
202
-
203
-
204
- def plot_splice_act(norm, pos, prob):
205
- return _heatmap_pil(norm, pos, _CMAP_ACT,
206
- "Splice Model — conv3 Activation Norm",
207
- "Activation", prob)
208
 
209
 
210
- def plot_v4_act(norm, pos, prob):
211
- return _heatmap_pil(norm, pos, _CMAP_ACT,
212
- "V4 Model — conv3 Activation Norm",
213
- "Activation", prob)
214
-
215
-
216
- def plot_classic_act(norm, pos, prob):
217
- return _heatmap_pil(norm, pos, _CMAP_ACT,
218
- "Classic Model — conv3 Activation Norm",
219
- "Activation", prob)
220
-
221
-
222
- def plot_splice_distance(ref_seq: str, mut_pos: int):
223
  seq = (ref_seq.upper() + "N" * 99)[:99]
224
  scores = np.zeros(99)
225
  donors, acceptors = [], []
226
- for i in range(len(seq) - 1):
227
  if seq[i:i+2] == "GT": donors.append(i)
228
  if seq[i:i+2] == "AG": acceptors.append(i)
229
  for p in donors:
230
- for d in range(-8, 9):
231
- if 0 <= p+d < 99:
232
- scores[p+d] = max(scores[p+d], 0.5)
233
  for p in acceptors:
234
- for d in range(-8, 9):
235
- if 0 <= p+d < 99:
236
- scores[p+d] = max(scores[p+d], 0.5)
237
  for p in donors:
238
  if 0 <= p < 99: scores[p] = 1.0
239
  for p in acceptors:
240
  if 0 <= p < 99: scores[p] = max(scores[p], 0.8)
241
- return _heatmap_pil(scores, mut_pos, _CMAP_SPLICE,
242
- "Splice Distance Risk Heatmap — GT/AG dinucleotides",
243
- "Splice risk")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
 
246
- def plot_gradient(attr, pos, label):
247
- return _heatmap_pil(attr, pos, _CMAP_GRAD,
248
- f"Gradient Attribution {label}",
249
- "Attribution")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
 
252
- def plot_counterfactual(cf: dict):
253
- table = cf.get("table", [])
254
- orig_p = cf.get("original_probability", 0)
255
- if not table:
256
  fig, ax = plt.subplots(figsize=(8, 3), facecolor=_BG)
257
- ax.set_facecolor(_BG)
258
- ax.text(0.5, 0.5, "Counterfactual analysis not available",
259
- color=_TEXT, ha="center", va="center",
260
- transform=ax.transAxes, fontsize=11)
261
  ax.axis("off")
262
- return _pil(fig)
263
 
264
- labels = [r["mutation"] for r in table]
265
- probs = [r["probability"] for r in table]
266
- p_max = cf.get("max_probability", max(probs))
267
- p_min = cf.get("min_probability", min(probs))
268
- colors = [_RED if r["probability"] == p_max
269
- else _BLUE if r["probability"] == p_min
270
- else "#74add1"
271
- for r in table]
272
 
273
  fig, ax = plt.subplots(figsize=(10, 3.5), facecolor=_BG)
274
- ax.set_facecolor(_SURF)
275
- bars = ax.bar(labels, probs, color=colors,
276
- edgecolor="#30363D", linewidth=0.7)
277
- ax.axhline(0.5, color=_MUTED, linestyle="--",
278
- linewidth=1.0, label="Decision boundary (0.5)")
279
- ax.axhline(orig_p, color=_ORG, linestyle="-.", linewidth=1.5,
280
- label=f"Original mutation ({orig_p:.3f})")
281
  ax.set_ylim(0, 1.05)
282
  ax.set_xlabel("Alternative mutation", color=_TEXT, fontsize=10)
283
  ax.set_ylabel("Pathogenicity probability", color=_TEXT, fontsize=10)
284
  ax.tick_params(colors=_TEXT)
285
- for sp in ["top", "right"]:
 
 
 
 
286
  ax.spines[sp].set_visible(False)
287
  ax.spines["left"].set_color("#333")
288
  ax.spines["bottom"].set_color("#333")
289
- for bar, p in zip(bars, probs):
290
- ax.text(bar.get_x() + bar.get_width()/2,
291
- bar.get_height() + 0.015,
292
- f"{p:.3f}", ha="center", va="bottom",
293
- fontsize=8, color=_TEXT)
294
- ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6)
295
  ax.set_title(
296
- f"Counterfactual Analysis | "
297
- f"Causal importance: {cf.get('probability_range', 0):.4f} | "
298
- f"Range: {p_min:.3f}–{p_max:.3f}",
299
- color=_TEXT, fontsize=9, loc="left", pad=4, fontweight="bold")
300
  fig.tight_layout()
301
- return _pil(fig)
302
 
303
 
304
- def plot_ablation(abl: dict):
305
- keys = ["splice_delta", "region_delta", "mutation_delta", "sequence_delta"]
306
- pkeys = ["splice_pct", "region_pct", "mutation_pct", "sequence_pct"]
307
  labels = [
308
  "Splice features\n(donor/acceptor/region)",
309
- "Region flags\n(exon/intron)",
310
  "Mutation type\n(one-hot)",
311
- "Sequence context\n(conv features)",
312
  ]
313
- colors = [_RED, _ORG, _BLUE, _GREEN]
314
- deltas = [abl.get(k, 0.0) for k in keys]
315
- pcts = [abl.get(k, 0.0) for k in pkeys]
 
 
316
 
317
- fig, ax = plt.subplots(figsize=(10, 3.5), facecolor=_BG)
318
- ax.set_facecolor(_SURF)
319
- bars = ax.barh(labels, deltas, color=colors,
320
- edgecolor="#30363D", linewidth=0.7)
321
- ax.set_xlabel("Probability delta (causal effect)",
322
- color=_TEXT, fontsize=9)
323
- ax.tick_params(colors=_TEXT)
324
- for sp in ["top", "right"]:
 
 
 
 
 
325
  ax.spines[sp].set_visible(False)
326
  ax.spines["left"].set_color("#333")
327
  ax.spines["bottom"].set_color("#333")
328
- for bar, d, p in zip(bars, deltas, pcts):
329
- ax.text(bar.get_width() + 0.002,
330
- bar.get_y() + bar.get_height()/2,
331
- f" Δ{d:.4f} ({p}%)",
332
- va="center", color=_TEXT, fontsize=8)
333
- ax.set_xlim(0, max(deltas) * 1.65 + 0.02)
334
- ax.set_title(
335
- f"Feature Ablation Causal Analysis | "
336
- f"Baseline: {abl.get('baseline_probability', 0):.4f}",
337
- color=_TEXT, fontsize=9, loc="left", pad=4, fontweight="bold")
338
  fig.tight_layout()
339
- return _pil(fig)
340
-
341
-
342
- def plot_xai_metrics(cross, sp_prob, v4_prob, cl_prob):
343
- """4-panel XAI metrics dashboard."""
344
- fig = plt.figure(figsize=(14, 7), facecolor=_BG)
345
- gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.45, wspace=0.35)
346
-
347
- # ── TL: per-model probs ───────────────────────────────────────────────────
348
- ax0 = fig.add_subplot(gs[0, 0])
349
- ax0.set_facecolor(_SURF)
350
- names = ["Splice", "V4", "Classic"]
351
- probs = [sp_prob, v4_prob, cl_prob]
352
- col0 = [_RED if p >= 0.5 else _BLUE for p in probs]
353
- bars0 = ax0.bar(names, probs, color=col0,
354
- edgecolor="#30363D", linewidth=0.7, width=0.5)
355
- ax0.axhline(0.5, color=_MUTED, linestyle="--", linewidth=1.0, alpha=0.7)
356
- ax0.set_ylim(0, 1.1)
357
- for bar, p in zip(bars0, probs):
358
- ax0.text(bar.get_x() + bar.get_width()/2,
359
- bar.get_height() + 0.02,
360
- f"{p:.4f}", ha="center", va="bottom",
361
- color=_TEXT, fontsize=9)
362
- ax0.set_ylabel("Pathogenicity probability", color=_TEXT, fontsize=9)
363
- ax0.tick_params(colors=_TEXT)
364
- for sp in ["top", "right"]:
365
- ax0.spines[sp].set_visible(False)
366
- ax0.spines["left"].set_color("#333")
367
- ax0.spines["bottom"].set_color("#333")
368
- _style_ax(ax0, "Per-model Probability")
369
-
370
- # ── TR: XAI scores ────────────────────────────────────────────────────────
371
- ax1 = fig.add_subplot(gs[0, 1])
372
- ax1.set_facecolor(_SURF)
373
- xai_labels = [
374
- "Mut Peak Ratio\n(÷3 norm)",
375
- "CF Magnitude",
376
- "Cross-Model\nLocality",
377
- "Signal\nConcentration",
378
- "Explainability\nStrength",
379
- ]
380
- xai_raw = [
381
- cross["mutation_peak_ratio"],
382
- cross["counterfactual_magnitude"],
383
- cross["cross_model_locality_score"],
384
- cross["signal_concentration_index"],
385
- cross["explainability_strength_score"],
386
- ]
387
- xai_norm = [
388
- min(cross["mutation_peak_ratio"] / 3.0, 1.0),
389
- min(cross["counterfactual_magnitude"], 1.0),
390
- (cross["cross_model_locality_score"] + 1.0) / 2.0,
391
- cross["signal_concentration_index"],
392
- cross["explainability_strength_score"],
393
  ]
394
- col1 = [_GREEN if v >= 0.5 else _ORG if v >= 0.3 else _RED
395
- for v in xai_norm]
396
- bars1 = ax1.barh(xai_labels, xai_norm, color=col1,
397
- edgecolor="#30363D", linewidth=0.7)
398
- ax1.set_xlim(0, 1.35)
399
- ax1.tick_params(colors=_TEXT, labelsize=7)
400
- for sp in ["top", "right"]:
401
- ax1.spines[sp].set_visible(False)
402
- ax1.spines["left"].set_color("#333")
403
- ax1.spines["bottom"].set_color("#333")
404
- for bar, raw in zip(bars1, xai_raw):
405
- ax1.text(bar.get_width() + 0.02,
406
- bar.get_y() + bar.get_height()/2,
407
- f"{raw:.3f}", va="center", color=_TEXT, fontsize=8)
408
- _style_ax(ax1, "XAI Engine Metrics (normalised 0–1)")
409
-
410
- # ── BL: cross-model activation overlap ────────────────────────────────────
411
- ax2 = fig.add_subplot(gs[1, 0])
412
- ax2.set_facecolor(_SURF)
413
- x = np.arange(99)
414
- sp_n = cross.get("_splice_norm", np.zeros(99))
415
- v4_n = cross.get("_v4_norm", np.zeros(99))
416
- cl_n = cross.get("_classic_norm", np.zeros(99))
417
- ax2.plot(x, sp_n, color=_RED, linewidth=1.2, alpha=0.85, label="Splice")
418
- ax2.plot(x, v4_n, color=_BLUE, linewidth=1.2, alpha=0.85, label="V4")
419
- ax2.plot(x, cl_n, color=_GREEN, linewidth=1.2, alpha=0.85, label="Classic")
420
- ax2.set_ylim(0, 1.15)
421
- ax2.set_xlabel("Position (99-bp window)", color=_TEXT, fontsize=8)
422
- ax2.set_ylabel("Norm. activation", color=_TEXT, fontsize=8)
423
- ax2.tick_params(colors=_TEXT, labelsize=7)
424
- for sp in ["top", "right"]:
425
- ax2.spines[sp].set_visible(False)
426
- ax2.spines["left"].set_color("#333")
427
- ax2.spines["bottom"].set_color("#333")
428
- ax2.legend(fontsize=7, facecolor=_BG, labelcolor=_TEXT,
429
- framealpha=0.6, loc="upper right")
430
- _style_ax(ax2, "Cross-model Activation Overlap")
431
-
432
- # ── BR: summary text ──────────────────────────────────────────────────────
433
- ax3 = fig.add_subplot(gs[1, 1])
434
- ax3.set_facecolor(_SURF)
435
- ax3.axis("off")
436
- summary = "\n".join([
437
- f"Activation pattern : {cross['activation_pattern_type']}",
438
- f"Model agreement : {cross['model_agreement']}",
439
- f"Probability std : {cross['prob_std']:.4f}",
440
- "",
441
- f"Splice prob : {sp_prob:.4f}",
442
- f"V4 prob : {v4_prob:.4f}",
443
- f"Classic prob : {cl_prob:.4f}",
444
- "",
445
- f"ESS score : {cross['explainability_strength_score']:.4f}",
446
- f"Cross-model loc. : {cross['cross_model_locality_score']:.4f}",
447
- ])
448
- ax3.text(0.05, 0.95, summary, transform=ax3.transAxes,
449
- color=_TEXT, fontsize=8, va="top",
450
- fontfamily="monospace",
451
- bbox=dict(facecolor="#21262D", edgecolor="#30363D",
452
- alpha=0.8, boxstyle="round,pad=0.4"))
453
- _style_ax(ax3, "Summary")
454
-
455
- return _pil(fig)
456
 
457
 
458
  # ═══════════════════════════════════════════════════════════════════════════════
459
- # Pipeline
460
  # ═══════════════════════════════════════════════════════════════════════════════
461
 
462
- _EMPTY_PIL = _empty_pil()
463
-
464
-
465
- def run_pipeline(chrom: str, pos_str: str,
466
- ref: str, alt: str,
467
- exon_flag: int, intron_flag: int):
468
- """
469
- Full XAI pipeline. Returns 13 outputs for the Gradio UI.
470
- The ordering of computation enforces explanation-before-prediction:
471
- Step 3: extract all internal signals
472
- Step 4: run explainability engine
473
- Step 5: build unified decision (uses step-4 results)
474
- """
475
 
476
- def _err(msg):
477
- empty = _empty_pil()
478
- return (
479
- f"❌ **Error**\n\n{msg}",
480
- msg,
481
- empty, empty, empty, empty, empty,
482
- empty, empty, empty, empty,
483
- "{}", None,
484
- )
485
-
486
- # ── Validate input ────────────────────────────────────────────────────────
487
  try:
488
- pos = int(str(pos_str).strip())
489
  except ValueError:
490
- return _err(f"Invalid position: '{pos_str}'")
491
-
492
- ref = ref.strip().upper()
493
- alt = alt.strip().upper()
494
- if len(ref) != 1 or ref not in "ACGTN":
495
- return _err(f"Ref base must be a single nucleotide. Got: '{ref}'")
496
- if len(alt) != 1 or alt not in "ACGTN":
497
- return _err(f"Alt base must be a single nucleotide. Got: '{alt}'")
498
- if ref == alt:
499
- return _err("Reference and alternate bases are identical.")
500
-
501
- exon_flag = int(exon_flag)
502
- intron_flag = int(intron_flag)
503
-
504
- # ── Step 1: Fetch 401-bp → trim to 99-bp window from Ensembl ─────────────
505
- logger.info(f"Fetching chr{chrom}:{pos} {ref}>{alt}")
506
- try:
507
- ref_seq, mut_seq, mut_win_pos = fetch_window(chrom, pos, ref, alt)
508
- except Exception as exc:
509
- logger.warning(f"Ensembl fetch failed ({exc}). Using synthetic window.")
510
- ref_seq = "N" * 49 + ref + "N" * 49
511
- mut_seq = "N" * 49 + alt + "N" * 49
512
- mut_win_pos = 49
513
 
514
- # ── Step 2: Load models ───────────────────────────────────────────────────
515
- try:
516
- splice_model = REGISTRY.splice
517
- v4_model = REGISTRY.v4
518
- classic_model = REGISTRY.classic
519
- except Exception as exc:
520
- return _err(f"Model loading failed: {exc}")
521
 
522
- # ── Step 3: Extract internal signals ─────────────────────────────────────
523
  try:
524
- logger.info("Extracting splice signals …")
 
 
 
 
 
 
 
 
 
 
 
 
525
  splice_sig = extract_splice_signals(
526
- splice_model, ref_seq, mut_seq, exon_flag, intron_flag)
527
- except Exception as exc:
528
- return _err(f"Splice model failed: {exc}\n{traceback.format_exc()}")
529
 
530
- try:
531
- logger.info("Extracting V4 signals …")
532
  v4_sig = extract_v4_signals(
533
- v4_model, ref_seq, mut_seq, exon_flag, intron_flag)
534
- except Exception as exc:
535
- logger.warning(f"V4 model failed ({exc}), using fallback.")
536
- v4_sig = V4Signals(
537
- probability=0.5, conv3_norm=np.zeros(99),
538
- gradient_attribution=np.zeros(99),
539
- mutation_pos=mut_win_pos,
540
- mutation_peak_ratio=0.0, signal_concentration=0.0,
541
- )
542
 
543
- try:
544
- logger.info("Extracting classic signals …")
545
  classic_sig = extract_classic_signals(
546
- classic_model, ref_seq, mut_seq, exon_flag, intron_flag)
547
- except Exception as exc:
548
- logger.warning(f"Classic model failed ({exc}), using fallback.")
549
- classic_sig = ClassicSignals(
550
- probability=0.5, conv3_norm=np.zeros(99),
551
- importance_head=0.0, region_imp=np.zeros(2),
552
- mutation_pos=mut_win_pos,
553
- mutation_peak_ratio=0.0, signal_concentration=0.0,
554
  )
555
 
556
- # ── Step 4: Explainability engine — MANDATORY before decision ─────────────
557
- logger.info("Running explainability engine …")
558
- cross = compute_cross_model_analysis(splice_sig, v4_sig, classic_sig)
559
 
560
- # ── Step 5: Unified decision (uses cross results — ordering guaranteed) ───
561
- logger.info("Building unified decision …")
562
- result: DecisionResult = build_decision(
563
- chrom, pos, ref, alt,
564
- splice_sig, v4_sig, classic_sig, cross)
565
 
566
- # ── Step 6: Build visualisations ──────────────────────────────────────────
567
- try:
568
- xai_metrics = plot_xai_metrics(
569
- cross, splice_sig.probability,
570
- v4_sig.probability, classic_sig.probability)
571
- splice_act = plot_splice_act(
572
- splice_sig.conv3_norm, splice_sig.mutation_pos,
573
- splice_sig.probability)
574
- splice_dist = plot_splice_distance(ref_seq, splice_sig.mutation_pos)
575
- v4_act = plot_v4_act(
576
- v4_sig.conv3_norm, v4_sig.mutation_pos, v4_sig.probability)
577
- classic_act = plot_classic_act(
578
- classic_sig.conv3_norm, classic_sig.mutation_pos,
579
- classic_sig.probability)
580
- v4_grad = plot_gradient(
581
- v4_sig.gradient_attribution, v4_sig.mutation_pos, "V4")
582
- splice_grad = plot_gradient(
583
- splice_sig.gradient_attribution, splice_sig.mutation_pos,
584
- "Splice")
585
- cf_plot = plot_counterfactual(splice_sig.counterfactual)
586
- abl_plot = plot_ablation(splice_sig.ablation)
587
- except Exception as exc:
588
- logger.error(f"Visualisation error: {exc}\n{traceback.format_exc()}")
589
- empty = _empty_pil()
590
- xai_metrics = splice_act = splice_dist = v4_act = empty
591
- classic_act = v4_grad = splice_grad = cf_plot = abl_plot = empty
592
 
593
- # ── Step 7: Downloadable JSON ─────────────────────────────────────────────
594
- json_str = result.report_json
595
- try:
596
- tmp = tempfile.NamedTemporaryFile(
597
- mode="w", suffix=".json",
598
- prefix=f"mutation_xai_{chrom}_{pos}_{ref}{alt}_",
599
- delete=False, encoding="utf-8")
600
- tmp.write(json_str)
601
- tmp.close()
602
- dl_path = tmp.name
603
- except Exception:
604
- dl_path = None
605
-
606
- # ── Step 8: Explanation-first summary markdown ────────────────────────────
607
- cf = splice_sig.counterfactual
608
- abl = splice_sig.ablation
609
- sp = splice_sig
610
- cross_loc = cross["cross_model_locality_score"]
611
-
612
- prob_icon = "🔴" if result.unified_probability >= 0.5 else "🟢"
613
- conf_icon = {"High": "✅", "Moderate": "⚠️", "Low": "🔶"}.get(
614
- result.confidence, "❓")
615
-
616
- summary_md = f"""
617
- ### {prob_icon} `{result.variant}`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
  | Field | Value |
620
  |---|---|
621
- | **Risk Tier** | `{result.risk_tier}` · {result.tier_desc} |
622
- | **Unified Probability** | `{result.unified_probability:.4f}` |
623
- | **Dominant Mechanism** | `{result.dominant_mechanism}` |
624
- | **Confidence** | {conf_icon} `{result.confidence}` |
 
 
 
625
 
626
  ---
627
 
628
- #### 🔬 Explainability Engine Output
629
 
630
- | Metric | Raw Value | Interpretation |
631
- |---|---|---|
632
- | Mutation Peak Ratio | `{cross["mutation_peak_ratio"]:.4f}` | {"Strongly localised to mutation site" if cross["mutation_peak_ratio"] > 2 else "Above-average localisation" if cross["mutation_peak_ratio"] > 1 else "Diffuse — signal not mutation-centred"} |
633
- | Counterfactual Magnitude | `{cross["counterfactual_magnitude"]:.4f}` | {"Strong position-level causality" if cross["counterfactual_magnitude"] > 0.25 else "Moderate causality" if cross["counterfactual_magnitude"] > 0.10 else "Weak positional causality"} |
634
- | Cross-model Locality | `{cross["cross_model_locality_score"]:.4f}` | {"Models align on same region" if cross_loc > 0.5 else "Partial alignment" if cross_loc > 0 else "Models attend to different regions"} |
635
- | Signal Concentration Index | `{cross["signal_concentration_index"]:.4f}` | Fraction of activation energy at mutation site |
636
- | **Explainability Strength (ESS)** | **`{cross["explainability_strength_score"]:.4f}`** | 0–1 composite quality score |
637
- | Activation Pattern | `{cross["activation_pattern_type"]}` | Shape of conv3 profile |
638
- | Model Agreement | `{cross["model_agreement"]}` | std={cross["prob_std"]:.4f} across models |
639
 
640
  ---
641
 
642
- #### 📊 Per-model Probabilities
643
 
644
- | Model | Probability |
645
- |---|---|
646
- | `mutation-predictor-splice` | `{sp.probability:.4f}` · {sp.risk_tier} |
647
- | `mutation-predictor-v4` | `{v4_sig.probability:.4f}` |
648
- | `mutation-pathogenicity-predictor` | `{classic_sig.probability:.4f}` |
649
-
650
- ---
651
 
652
- #### ⚗️ Splice Signals
653
 
654
- | Signal | Value |
655
- |---|---|
656
- | Splice aura score | `{sp.splice_aura_score:.4f}` |
657
- | Donor importance | `{float(sp.splice_imp[0]):.4f}` |
658
- | Acceptor importance | `{float(sp.splice_imp[1]):.4f}` |
659
- | Nearest GT donor | `{sp.dist_donor if sp.dist_donor is not None else "N/A"} bp` — {sp.splice_risk_donor} |
660
- | Nearest AG acceptor | `{sp.dist_acceptor if sp.dist_acceptor is not None else "N/A"} bp` — {sp.splice_risk_acceptor} |
661
- | Counterfactual delta | `{cf.get("probability_range", 0):.4f}` |
662
- | Dominant ablation feature | `{abl.get("dominant_feature", "—")}` |
663
  """
664
 
665
- logger.info("Pipeline complete.")
666
 
 
 
667
  return (
668
- summary_md, # ① explanation summary — FIRST
669
- result.final_explanation, # ② final human-readable explanation
670
- xai_metrics, # ③ XAI metrics dashboard
671
- splice_act, # ④ splice conv3 heatmap
672
- splice_dist, # ⑤ splice distance heatmap
673
- v4_act, # ⑥ v4 conv3 heatmap
674
- classic_act, # ⑦ classic conv3 heatmap
675
- v4_grad, # ⑧ v4 gradient attribution
676
- splice_grad, # ⑨ splice gradient attribution
677
- cf_plot, # ⑩ counterfactual chart
678
- abl_plot, # ⑪ feature ablation chart
679
- json_str, # ⑫ JSON report text
680
- dl_path, # ⑬ downloadable file
681
  )
682
 
683
 
@@ -686,220 +528,4 @@ def run_pipeline(chrom: str, pos_str: str,
686
  # ═══════════════════════════════════════════════════════════════════════════════
687
 
688
  CSS = """
689
- @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;400;600;700&display=swap');
690
- :root {
691
- --bg:#0D1117; --surface:#161B22; --border:#30363D;
692
- --text:#E6EDF3; --muted:#7D8590;
693
- --blue:#58A6FF; --green:#3FB950; --red:#F85149; --orange:#D29922;
694
- --font:'Inter',system-ui; --mono:'JetBrains Mono',monospace;
695
- }
696
- body,.gradio-container{background:var(--bg)!important;color:var(--text)!important;font-family:var(--font)!important;}
697
- .xai-header{background:linear-gradient(135deg,#0D1117 0%,#161B22 60%,#1a2332 100%);
698
- border-bottom:1px solid var(--border);padding:2rem 2.5rem 1.5rem;margin-bottom:1.5rem;}
699
- .xai-header h1{font-size:1.7rem;font-weight:700;letter-spacing:-.03em;margin:0 0 .3rem;}
700
- .xai-header h1 em{color:var(--blue);font-style:normal;}
701
- .xai-header p{color:var(--muted);font-size:.82rem;margin:0;}
702
- .section-title{font-size:.68rem;font-weight:600;letter-spacing:.12em;text-transform:uppercase;
703
- color:var(--muted);border-bottom:1px solid var(--border);padding-bottom:.4rem;margin-bottom:1rem;}
704
- .gradio-textbox input,.gradio-textbox textarea,.gradio-number input{
705
- background:#161B22!important;border:1px solid var(--border)!important;
706
- color:var(--text)!important;border-radius:6px!important;
707
- font-family:var(--mono)!important;font-size:.88rem!important;}
708
- label span{color:var(--muted)!important;font-size:.76rem!important;font-weight:500!important;}
709
- .run-btn{background:linear-gradient(135deg,#1f6feb 0%,#388bfd 100%)!important;
710
- border:none!important;color:white!important;font-weight:700!important;
711
- font-size:.92rem!important;border-radius:6px!important;letter-spacing:.04em!important;}
712
- .run-btn:hover{transform:translateY(-1px)!important;box-shadow:0 4px 14px rgba(88,166,255,.35)!important;}
713
- .explanation-panel{border:1px solid var(--blue)!important;border-radius:8px!important;
714
- background:rgba(88,166,255,.04)!important;padding:1rem!important;}
715
- .gradio-markdown table{border-collapse:collapse;width:100%;font-size:.83rem;}
716
- .gradio-markdown th{background:#161B22;color:var(--muted);font-size:.68rem;
717
- letter-spacing:.08em;text-transform:uppercase;padding:.45rem .7rem;border:1px solid var(--border);}
718
- .gradio-markdown td{padding:.42rem .7rem;border:1px solid var(--border);
719
- font-family:var(--mono);font-size:.80rem;}
720
- .gradio-markdown code{background:#161B22;padding:1px 5px;border-radius:3px;
721
- font-family:var(--mono);color:var(--blue);font-size:.85em;}
722
- .gradio-image img{border-radius:6px;border:1px solid var(--border);}
723
- .gradio-tabs button{font-size:.80rem!important;color:var(--muted)!important;
724
- border-bottom:2px solid transparent!important;background:transparent!important;}
725
- .gradio-tabs button[aria-selected=true]{color:var(--blue)!important;border-bottom-color:var(--blue)!important;}
726
- .gradio-textbox textarea{font-family:var(--mono)!important;font-size:.76rem!important;line-height:1.5!important;}
727
- """
728
-
729
- HEADER_HTML = """
730
- <div class="xai-header">
731
- <h1>Mutation <em>Explainability</em> Intelligence System</h1>
732
- <p>
733
- Three-model ensemble &nbsp;·&nbsp; Explanation always before prediction &nbsp;·&nbsp;
734
- conv3 activations &nbsp;·&nbsp; gradient attribution &nbsp;·&nbsp;
735
- counterfactual analysis &nbsp;·&nbsp; feature ablation &nbsp;·&nbsp;
736
- splice distance &nbsp;·&nbsp; cross-model locality
737
- </p>
738
- </div>
739
- """
740
-
741
- EXAMPLES = [
742
- ["17", "43071077", "G", "A", 1, 0],
743
- ["11", "5226929", "T", "C", 1, 0],
744
- ["7", "117548628","T", "A", 1, 0],
745
- ["3", "37053577", "A", "C", 0, 1],
746
- ["19", "44908684", "G", "T", 1, 0],
747
- ]
748
-
749
-
750
- def build_ui() -> gr.Blocks:
751
- with gr.Blocks(
752
- title="Mutation Explainability Intelligence System",
753
- css=CSS,
754
- ) as demo:
755
-
756
- gr.HTML(HEADER_HTML)
757
-
758
- with gr.Row(equal_height=False):
759
-
760
- # ── INPUT PANEL ───────────────────────────────────────────────────
761
- with gr.Column(scale=1, min_width=280):
762
- gr.HTML('<div class="section-title">Variant Input</div>')
763
- chrom_in = gr.Textbox(label="Chromosome",
764
- value="17", max_lines=1)
765
- pos_in = gr.Textbox(label="Position (hg38, 1-based)",
766
- value="43071077", max_lines=1)
767
- with gr.Row():
768
- ref_in = gr.Textbox(label="Ref Base", value="G",
769
- max_lines=1)
770
- alt_in = gr.Textbox(label="Alt Base", value="A",
771
- max_lines=1)
772
- with gr.Row():
773
- exon_in = gr.Radio([0, 1], label="Exon flag", value=1)
774
- intron_in = gr.Radio([0, 1], label="Intron flag", value=0)
775
-
776
- run_btn = gr.Button("▶ Analyse Variant",
777
- variant="primary",
778
- elem_classes="run-btn")
779
-
780
- gr.HTML('<div class="section-title" style="margin-top:1rem">'
781
- 'Examples</div>')
782
- gr.Examples(
783
- examples=EXAMPLES,
784
- inputs=[chrom_in, pos_in, ref_in, alt_in,
785
- exon_in, intron_in],
786
- label="",
787
- examples_per_page=5,
788
- )
789
-
790
- # ── OUTPUT PANEL ──────────────────────────────────────────────────
791
- with gr.Column(scale=3, min_width=640):
792
-
793
- # ══════════════════════════════════════════════════════════════
794
- # ① EXPLANATION PANEL — ALWAYS RENDERED FIRST
795
- # Prediction score does not appear without this panel
796
- # ══════════════════════════════════════════════════════════════
797
- gr.HTML('<div class="section-title">'
798
- '① Explanation &amp; Signal Analysis</div>')
799
-
800
- summary_out = gr.Markdown(
801
- value=(
802
- "*Run an analysis to see the full explanation.*\n\n"
803
- "*This panel always renders **before** the prediction score.*"
804
- ),
805
- elem_classes="explanation-panel",
806
- )
807
-
808
- final_exp_out = gr.Textbox(
809
- label="Final Explanation (grounded in internal signals)",
810
- lines=10, max_lines=18,
811
- show_copy_button=True,
812
- )
813
-
814
- # ── ② XAI Metrics Dashboard ───────────────────────────────────
815
- gr.HTML('<div class="section-title" style="margin-top:1.5rem">'
816
- '② Explainability Metrics Panel</div>')
817
- xai_metrics_plot = gr.Image(label="XAI Metrics Dashboard")
818
-
819
- # ── ③ Internal model signal tabs ──────────────────────────────
820
- gr.HTML('<div class="section-title" style="margin-top:1.5rem">'
821
- '③ Internal Model Signals</div>')
822
-
823
- with gr.Tabs():
824
- with gr.TabItem("🔬 Splice Model"):
825
- splice_act_plot = gr.Image(
826
- label="conv3 Activation Heatmap — Splice")
827
- splice_dist_plot = gr.Image(
828
- label="Splice Distance Risk Heatmap")
829
- splice_grad_plot = gr.Image(
830
- label="Gradient Attribution — Splice")
831
-
832
- with gr.TabItem("🧬 V4 Model"):
833
- v4_act_plot = gr.Image(
834
- label="conv3 Activation Heatmap — V4")
835
- v4_grad_plot = gr.Image(
836
- label="Gradient Attribution — V4")
837
-
838
- with gr.TabItem("📊 Classic Model"):
839
- classic_act_plot = gr.Image(
840
- label="conv3 Activation Heatmap — Classic")
841
-
842
- with gr.TabItem("⚗️ Causal Analysis"):
843
- cf_plot = gr.Image(
844
- label="Counterfactual Mutation Analysis")
845
- abl_plot = gr.Image(
846
- label="Feature Ablation Causal Chart")
847
-
848
- with gr.TabItem("📋 JSON Report"):
849
- json_out = gr.Textbox(
850
- label="Structured JSON Report",
851
- lines=30, max_lines=60,
852
- show_copy_button=True,
853
- )
854
- dl_btn = gr.File(
855
- label="⬇ Download JSON Report")
856
-
857
- # ── Wire all outputs ──────────────────────────────────────────────────
858
- all_outputs = [
859
- summary_out, # ① explanation summary (always first)
860
- final_exp_out, # ① detailed explanation text
861
- xai_metrics_plot, # ② XAI dashboard
862
- splice_act_plot, # ③ splice tab
863
- splice_dist_plot,
864
- v4_act_plot, # ③ v4 tab
865
- classic_act_plot, # ③ classic tab
866
- v4_grad_plot,
867
- splice_grad_plot,
868
- cf_plot, # ③ causal tab
869
- abl_plot,
870
- json_out, # ③ JSON tab
871
- dl_btn,
872
- ]
873
-
874
- run_btn.click(
875
- fn=run_pipeline,
876
- inputs=[chrom_in, pos_in, ref_in, alt_in,
877
- exon_in, intron_in],
878
- outputs=all_outputs,
879
- show_progress=True,
880
- )
881
-
882
- gr.HTML("""
883
- <div style="text-align:center;color:#7D8590;font-size:.70rem;
884
- padding:1rem;margin-top:1rem;border-top:1px solid #30363D;">
885
- Mutation Explainability Intelligence System
886
- &nbsp;·&nbsp;
887
- Models: nileshhanotia/{mutation-predictor-splice,
888
- mutation-predictor-v4, mutation-pathogenicity-predictor}
889
- &nbsp;·&nbsp; For Research Use Only &nbsp;·&nbsp;
890
- Not for Clinical Diagnosis
891
- </div>
892
- """)
893
-
894
- return demo
895
-
896
-
897
- demo = build_ui()
898
-
899
- if __name__ == "__main__":
900
- demo.launch(
901
- server_name="0.0.0.0",
902
- server_port=7860,
903
- show_error=True,
904
- share=False,
905
- )
 
2
  app.py
3
  ======
4
  Mutation Explainability Intelligence System
5
+ Gradio Space — explanation-first clinical variant analysis
6
 
7
  Three models:
8
  nileshhanotia/mutation-predictor-splice
9
  nileshhanotia/mutation-predictor-v4
10
  nileshhanotia/mutation-pathogenicity-predictor
11
+
12
+ Explanation ALWAYS precedes prediction panel.
13
  """
14
 
15
  from __future__ import annotations
 
17
  import json
18
  import logging
19
  import os
20
+ import sys
21
  import tempfile
 
22
  import traceback
 
23
 
24
  import gradio as gr
25
  import numpy as np
 
28
  import matplotlib.pyplot as plt
29
  import matplotlib.gridspec as gridspec
30
  from matplotlib.colors import LinearSegmentedColormap
31
+
32
+ def _fig_to_pil(fig):
33
+ """Render matplotlib figure to PIL Image — required for gr.Image in Gradio 4.44."""
34
+ buf = io.BytesIO()
35
+ fig.savefig(buf, format="png", dpi=110, bbox_inches="tight",
36
+ facecolor=fig.get_facecolor())
37
+ buf.seek(0)
38
+ from PIL import Image as _PILImage
39
+ img = _PILImage.open(buf).copy()
40
+ plt.close(fig)
41
+ return img
42
+
43
+
44
  import requests
45
+ import time
46
+ from functools import lru_cache
47
 
48
+ # ── project imports ───────────────────────────────────────────────────────────
49
+ from model_loader import (
50
+ ModelRegistry,
51
+ encode_for_v2,
52
+ find_mutation_pos,
53
+ )
54
  from explainability_engine import (
55
  extract_splice_signals,
56
  extract_v4_signals,
57
  extract_classic_signals,
58
  compute_cross_model_analysis,
 
 
59
  )
60
  from decision_engine import build_decision, DecisionResult
61
 
 
65
  )
66
  logger = logging.getLogger("mutation_xai")
67
 
68
+ # ── Global registry (lazy) ────────────────────────────────────────────────────
 
 
 
 
69
  REGISTRY = ModelRegistry(hf_token=os.environ.get("HF_TOKEN"))
70
 
71
+ # ── Ensembl fetch ─────────────────────────────────────────────────────────────
 
 
 
 
72
  ENSEMBL_URL = "https://rest.ensembl.org/sequence/region/human"
73
+ WINDOW_HALF = 49 # 49 + 1 + 49 = 99 bp (matches all three models)
74
 
75
 
76
+ @lru_cache(maxsize=256)
77
  def _fetch_ensembl(chrom: str, start: int, end: int) -> str:
78
+ chrom = chrom.lstrip("chrCHR").strip()
79
  region = f"{chrom}:{start}..{end}:1"
80
  url = f"{ENSEMBL_URL}/{region}"
81
  for attempt in range(3):
82
  try:
83
+ r = requests.get(url, params={"content-type": "application/json"}, timeout=15)
 
 
84
  if r.status_code == 429:
85
+ time.sleep(int(r.headers.get("Retry-After", 5)))
 
 
86
  continue
87
  r.raise_for_status()
88
  data = r.json()
89
+ if isinstance(data, list): data = data[0]
 
90
  return data.get("seq", "").upper()
91
+ except Exception as e:
92
  if attempt == 2:
93
+ raise RuntimeError(f"Ensembl API failed: {e}")
 
94
  time.sleep(1.5 * (2 ** attempt))
95
  return ""
96
 
97
 
98
+ def fetch_window(chrom: str, pos: int) -> tuple[str, str, int]:
99
+ """
100
+ Returns (ref_seq_99bp, mut_seq_placeholder, mutation_pos_in_window).
101
+ Caller must insert the alt base into mut_seq at mutation_pos.
102
+ """
103
+ chrom_clean = chrom.lstrip("chrCHR").strip()
104
+ start = max(1, pos - WINDOW_HALF)
105
+ end = pos + WINDOW_HALF
106
+ seq = _fetch_ensembl(chrom_clean, start, end)
107
+ if len(seq) < 1:
108
+ raise ValueError(f"Empty sequence returned for chr{chrom}:{start}-{end}")
109
+ # Pad/trim to 99
110
+ seq = (seq + "N" * 99)[:99]
111
+ mut_pos = pos - start # 0-indexed position within window
112
+ mut_pos = max(0, min(98, mut_pos))
113
+ return seq, mut_pos
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  # ═══════════════════════════════════════════════════════════════════════════════
117
+ # Visualisation helpers
118
  # ═══════════════════════════════════════════════════════════════════════════════
119
 
120
  _BG = "#0D1117"
 
121
  _TEXT = "#E6EDF3"
122
  _MUTED = "#7D8590"
123
  _BLUE = "#58A6FF"
 
125
  _RED = "#F85149"
126
  _ORG = "#D29922"
127
 
128
+ _CMAP_ACTIVATION = LinearSegmentedColormap.from_list(
129
+ "act", [(0.04,0.22,0.47),(0.96,0.96,0.96),(0.72,0.05,0.12)], N=256)
 
 
130
  _CMAP_SPLICE = LinearSegmentedColormap.from_list(
131
+ "splice", [(0.0,"#f7f7f7"),(0.3,"#fee08b"),(0.6,"#fc8d59"),(1.0,"#d73027")])
 
 
132
 
133
 
134
+ def _fig_base(w=15, h=2.8):
135
+ fig, ax = plt.subplots(figsize=(w, h), facecolor=_BG)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  ax.set_facecolor(_BG)
137
+ return fig, ax
 
138
 
139
 
140
+ def _style_ax(ax, title):
141
+ ax.set_title(title, color=_TEXT, fontsize=9, loc="left", pad=4, fontweight="bold")
142
+ for sp in ["top","right"]:
 
143
  ax.spines[sp].set_visible(False)
144
  ax.spines["left"].set_color("#333")
145
  ax.spines["bottom"].set_color("#333")
146
  ax.tick_params(colors=_TEXT, labelsize=7)
147
 
148
 
149
+ def plot_activation_heatmap(profile: np.ndarray, mutation_pos: int,
150
+ label: str, prob: float):
 
151
  imp = profile.copy()
152
  if imp.max() > 0:
153
  imp /= imp.max()
154
+ fig, ax = _fig_base(15, 2.5)
155
+ im = ax.imshow(imp[np.newaxis,:], aspect="auto", cmap=_CMAP_ACTIVATION,
 
156
  vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1])
157
  if mutation_pos >= 0:
158
+ ax.axvline(x=mutation_pos, color=_GREEN, linewidth=2.0, linestyle="--",
159
+ label=f"Mutation pos {mutation_pos}")
160
+ ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6,
161
+ loc="upper right")
162
  cb = fig.colorbar(im, ax=ax, pad=0.01)
163
+ cb.set_label("Activation intensity", color=_TEXT, fontsize=8)
164
  cb.ax.tick_params(colors=_TEXT, labelsize=7)
165
+ ax.set_xlabel("Nucleotide position (99 bp window)", color=_TEXT, fontsize=9)
 
166
  ax.set_xticks(range(0, 99, 10))
167
  ax.set_yticks([])
168
+ _style_ax(ax, f"CNN conv3 Activation — {label} (prob={prob:.4f})")
 
169
  fig.tight_layout()
170
+ return _fig_to_pil(fig)
 
 
 
 
 
 
171
 
172
 
173
+ def plot_splice_heatmap(ref_seq: str, mutation_pos: int):
 
 
 
 
 
 
 
 
 
 
 
 
174
  seq = (ref_seq.upper() + "N" * 99)[:99]
175
  scores = np.zeros(99)
176
  donors, acceptors = [], []
177
+ for i in range(len(seq)-1):
178
  if seq[i:i+2] == "GT": donors.append(i)
179
  if seq[i:i+2] == "AG": acceptors.append(i)
180
  for p in donors:
181
+ for d in range(-8,9):
182
+ if 0 <= p+d < 99: scores[p+d] = max(scores[p+d], 0.5)
 
183
  for p in acceptors:
184
+ for d in range(-8,9):
185
+ if 0 <= p+d < 99: scores[p+d] = max(scores[p+d], 0.5)
 
186
  for p in donors:
187
  if 0 <= p < 99: scores[p] = 1.0
188
  for p in acceptors:
189
  if 0 <= p < 99: scores[p] = max(scores[p], 0.8)
190
+
191
+ fig, ax = _fig_base(15, 2.5)
192
+ im = ax.imshow(scores[np.newaxis,:], aspect="auto", cmap=_CMAP_SPLICE,
193
+ vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1])
194
+ if mutation_pos >= 0:
195
+ ax.axvline(x=mutation_pos, color=_BLUE, linewidth=2.0, linestyle="--",
196
+ label=f"Mutation pos {mutation_pos}")
197
+ ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6,
198
+ loc="upper right")
199
+ cb = fig.colorbar(im, ax=ax, pad=0.01)
200
+ cb.set_label("Splice risk", color=_TEXT, fontsize=8)
201
+ cb.ax.tick_params(colors=_TEXT, labelsize=7)
202
+ ax.set_xlabel("Nucleotide position (99 bp window)", color=_TEXT, fontsize=9)
203
+ ax.set_xticks(range(0, 99, 10))
204
+ ax.set_yticks([])
205
+ _style_ax(ax, "Splice Distance Risk — GT donor / AG acceptor signals")
206
+ fig.tight_layout()
207
+ return _fig_to_pil(fig)
208
 
209
 
210
+ def plot_gradient_heatmap(attr: np.ndarray, mutation_pos: int, label: str):
211
+ fig, ax = _fig_base(15, 2.5)
212
+ im = ax.imshow(attr[np.newaxis,:], aspect="auto", cmap="PuOr",
213
+ vmin=0, vmax=1, extent=[-0.5, 98.5, 0, 1])
214
+ if mutation_pos >= 0:
215
+ ax.axvline(x=mutation_pos, color=_GREEN, linewidth=2.0, linestyle="--",
216
+ label=f"Mutation pos {mutation_pos}")
217
+ ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.6,
218
+ loc="upper right")
219
+ cb = fig.colorbar(im, ax=ax, pad=0.01)
220
+ cb.set_label("Gradient attribution", color=_TEXT, fontsize=8)
221
+ cb.ax.tick_params(colors=_TEXT, labelsize=7)
222
+ ax.set_xlabel("Nucleotide position", color=_TEXT, fontsize=9)
223
+ ax.set_xticks(range(0, 99, 10))
224
+ ax.set_yticks([])
225
+ _style_ax(ax, f"Gradient Attribution Map — {label}")
226
+ fig.tight_layout()
227
+ return _fig_to_pil(fig)
228
 
229
 
230
+ def plot_counterfactual(cf_table: list[dict], orig_prob: float, cf_delta: float):
231
+ if not cf_table:
 
 
232
  fig, ax = plt.subplots(figsize=(8, 3), facecolor=_BG)
233
+ ax.text(0.5, 0.5, "No counterfactual data", ha="center", va="center",
234
+ color=_TEXT, fontsize=12)
 
 
235
  ax.axis("off")
236
+ return _fig_to_pil(fig)
237
 
238
+ labels = [r["mutation"] for r in cf_table]
239
+ probs = [r["probability"] for r in cf_table]
240
+ max_p, min_p = max(probs), min(probs)
241
+ colors = [_RED if p == max_p else (_BLUE if p == min_p else "#74add1") for p in probs]
 
 
 
 
242
 
243
  fig, ax = plt.subplots(figsize=(10, 3.5), facecolor=_BG)
244
+ ax.set_facecolor(_BG)
245
+ bars = ax.bar(labels, probs, color=colors, edgecolor="#444", linewidth=0.7)
246
+ ax.axhline(0.5, color=_MUTED, linestyle="--", linewidth=1.0, label="Decision boundary (0.5)")
247
+ ax.axhline(orig_prob, color=_ORG, linestyle="-.", linewidth=1.5,
248
+ label=f"Original mutation ({orig_prob:.3f})")
 
 
249
  ax.set_ylim(0, 1.05)
250
  ax.set_xlabel("Alternative mutation", color=_TEXT, fontsize=10)
251
  ax.set_ylabel("Pathogenicity probability", color=_TEXT, fontsize=10)
252
  ax.tick_params(colors=_TEXT)
253
+ ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.5)
254
+ for b, p in zip(bars, probs):
255
+ ax.text(b.get_x() + b.get_width()/2, b.get_height()+0.01,
256
+ f"{p:.3f}", ha="center", va="bottom", fontsize=8, color=_TEXT)
257
+ for sp in ["top","right"]:
258
  ax.spines[sp].set_visible(False)
259
  ax.spines["left"].set_color("#333")
260
  ax.spines["bottom"].set_color("#333")
 
 
 
 
 
 
261
  ax.set_title(
262
+ f"Counterfactual Analysis | Δ={cf_delta:.4f} | "
263
+ f"range {min_p:.3f}–{max_p:.3f}",
264
+ color=_TEXT, fontsize=10, loc="left")
 
265
  fig.tight_layout()
266
+ return _fig_to_pil(fig)
267
 
268
 
269
+ def plot_ablation(ablation: dict):
 
 
270
  labels = [
271
  "Splice features\n(donor/acceptor/region)",
272
+ "Region features\n(exon/intron flags)",
273
  "Mutation type\n(one-hot)",
 
274
  ]
275
+ deltas = [ablation["splice_causal_effect"],
276
+ ablation["region_causal_effect"],
277
+ ablation["mutation_causal_effect"]]
278
+ pcts = [ablation["splice_pct"], ablation["region_pct"], ablation["mutation_pct"]]
279
+ colors = [_RED, _ORG, _BLUE]
280
 
281
+ fig, ax = plt.subplots(figsize=(9, 3.0), facecolor=_BG)
282
+ ax.set_facecolor(_BG)
283
+ bars = ax.barh(labels, deltas, color=colors, edgecolor="#444", linewidth=0.6)
284
+ ax.set_xlabel("Probability delta when ablated (causal effect)", color=_TEXT, fontsize=9)
285
+ ax.tick_params(colors=_TEXT, labelsize=8)
286
+ ax.set_title(
287
+ f"Feature Ablation | baseline prob={ablation['baseline_probability']:.4f}",
288
+ color=_TEXT, fontsize=10, loc="left")
289
+ for b, d, p in zip(bars, deltas, pcts):
290
+ ax.text(b.get_width()+0.002, b.get_y()+b.get_height()/2,
291
+ f" Δ{d:.4f} ({p}%)", va="center", fontsize=9, color=_TEXT)
292
+ ax.set_xlim(0, max(deltas+[0.01]) * 1.6)
293
+ for sp in ["top","right"]:
294
  ax.spines[sp].set_visible(False)
295
  ax.spines["left"].set_color("#333")
296
  ax.spines["bottom"].set_color("#333")
 
 
 
 
 
 
 
 
 
 
297
  fig.tight_layout()
298
+ return _fig_to_pil(fig)
299
+
300
+
301
+ def plot_xai_metrics(xai):
302
+ """Radar-style bar chart of explainability metrics."""
303
+ labels = ["Model\nAgreement", "XAI\nStrength", "CF\nMagnitude",
304
+ "Locality\nScore", "Concentration\nIndex"]
305
+ values = [
306
+ xai.model_agreement,
307
+ xai.explainability_strength,
308
+ min(xai.counterfactual_magnitude / 0.4, 1.0),
309
+ xai.cross_model_locality_score,
310
+ xai.signal_concentration_index,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  ]
312
+ colors = [_GREEN if v >= 0.65 else (_ORG if v >= 0.40 else _RED) for v in values]
313
+
314
+ fig, ax = plt.subplots(figsize=(10, 3.0), facecolor=_BG)
315
+ ax.set_facecolor(_BG)
316
+ bars = ax.bar(labels, values, color=colors, edgecolor="#444", linewidth=0.6, width=0.5)
317
+ ax.axhline(0.65, color=_GREEN, linestyle="--", linewidth=0.8, alpha=0.6, label="High (≥0.65)")
318
+ ax.axhline(0.40, color=_ORG, linestyle="--", linewidth=0.8, alpha=0.6, label="Moderate (≥0.40)")
319
+ ax.set_ylim(0, 1.1)
320
+ ax.set_ylabel("Score (0–1)", color=_TEXT, fontsize=9)
321
+ ax.tick_params(colors=_TEXT, labelsize=8)
322
+ ax.legend(fontsize=8, facecolor=_BG, labelcolor=_TEXT, framealpha=0.4, loc="upper right")
323
+ for b, v in zip(bars, values):
324
+ ax.text(b.get_x()+b.get_width()/2, b.get_height()+0.02,
325
+ f"{v:.3f}", ha="center", fontsize=9, color=_TEXT)
326
+ for sp in ["top","right"]:
327
+ ax.spines[sp].set_visible(False)
328
+ ax.spines["left"].set_color("#333")
329
+ ax.spines["bottom"].set_color("#333")
330
+ ax.set_title("Explainability Metrics Panel", color=_TEXT, fontsize=10, loc="left")
331
+ fig.tight_layout()
332
+ return _fig_to_pil(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
 
335
  # ═══════════════════════════════════════════════════════════════════════════════
336
+ # Core pipeline
337
  # ═══════════════════════════════════════════════════════════════════════════════
338
 
339
+ def run_pipeline(
340
+ chrom: str,
341
+ position: str,
342
+ ref_base: str,
343
+ alt_base: str,
344
+ exon_flag: int,
345
+ intron_flag: int,
346
+ ):
347
+ """Main Gradio callback. Returns all outputs."""
348
+ chrom = chrom.strip()
349
+ ref_base = ref_base.strip().upper()
350
+ alt_base = alt_base.strip().upper()
 
351
 
 
 
 
 
 
 
 
 
 
 
 
352
  try:
353
+ pos = int(position.strip().replace(",",""))
354
  except ValueError:
355
+ return _error(f"Invalid position: '{position}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
+ for b, name in [(ref_base,"Reference"),(alt_base,"Alternate")]:
358
+ if b not in "ACGT" or len(b) != 1:
359
+ return _error(f"{name} base must be A, C, G, or T. Got: '{b}'")
360
+ if ref_base == alt_base:
361
+ return _error("Reference and alternate bases are identical.")
 
 
362
 
 
363
  try:
364
+ ref_seq, mutation_pos = fetch_window(chrom, pos)
365
+
366
+ # Validate reference base
367
+ actual_ref = ref_seq[mutation_pos].upper()
368
+ if actual_ref != ref_base:
369
+ return _error(
370
+ f"Reference mismatch at chr{chrom}:{pos}: "
371
+ f"genome has '{actual_ref}', you entered '{ref_base}'."
372
+ )
373
+
374
+ # Build mutated sequence
375
+ mut_seq = ref_seq[:mutation_pos] + alt_base + ref_seq[mutation_pos+1:]
376
+
377
  splice_sig = extract_splice_signals(
378
+ REGISTRY.splice, ref_seq, mut_seq, exon_flag, intron_flag)
 
 
379
 
 
 
380
  v4_sig = extract_v4_signals(
381
+ REGISTRY.v4, ref_seq, mut_seq, exon_flag, intron_flag)
 
 
 
 
 
 
 
 
382
 
 
 
383
  classic_sig = extract_classic_signals(
384
+ REGISTRY.classic, ref_seq, mut_seq)
385
+
386
+ xai = compute_cross_model_analysis(splice_sig, v4_sig, classic_sig, mutation_pos)
387
+
388
+ result = build_decision(
389
+ chrom=chrom, pos=pos, ref=ref_base, alt=alt_base,
390
+ ref_seq=ref_seq, mut_seq=mut_seq, mutation_pos=mutation_pos,
391
+ splice=splice_sig, v4=v4_sig, classic=classic_sig, xai=xai,
392
  )
393
 
394
+ plots = _build_all_plots(result)
 
 
395
 
396
+ json_str = result.to_json()
397
+ json_file = _write_json_file(json_str)
 
 
 
398
 
399
+ demo_banner = (
400
+ "\n> ⚠️ **DEMO MODE** — models are running with random weights. "
401
+ "Place real checkpoints or ensure HF_TOKEN is set.\n"
402
+ if REGISTRY.demo_mode else ""
403
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
+ summary_md = _build_summary_md(result, demo_banner)
406
+
407
+ return (
408
+ summary_md, # 0: explanation summary (FIRST)
409
+ result.final_explanation, # 1: final explanation text
410
+ plots["xai_metrics"], # 2: XAI metrics panel
411
+ plots["splice_activation"], # 3: splice conv3 heatmap
412
+ plots["splice_heatmap"], # 4: splice distance heatmap
413
+ plots["v4_activation"], # 5: v4 conv3 heatmap
414
+ plots["classic_activation"], # 6: classic conv3 heatmap
415
+ plots["v4_gradient"], # 7: v4 gradient attribution
416
+ plots["splice_gradient"], # 8: splice gradient attribution
417
+ plots["counterfactual"], # 9: counterfactual chart
418
+ plots["ablation"], # 10: ablation chart
419
+ json_str, # 11: JSON report
420
+ json_file, # 12: download file
421
+ )
422
+
423
+ except Exception as exc:
424
+ logger.error("Pipeline error: %s\n%s", exc, traceback.format_exc())
425
+ return _error(f"Error: {exc}\n\n```\n{traceback.format_exc()}\n```")
426
+
427
+
428
+ def _build_all_plots(r: DecisionResult) -> dict:
429
+ mp = r.mutation_pos
430
+ return {
431
+ "xai_metrics": plot_xai_metrics(r.xai),
432
+ "splice_activation": plot_activation_heatmap(
433
+ r.splice.conv3_profile, mp, "Splice Model", r.splice.probability),
434
+ "splice_heatmap": plot_splice_heatmap(r.ref_seq, mp),
435
+ "v4_activation": plot_activation_heatmap(
436
+ r.v4.conv3_profile, mp, "V4 Model", r.v4.probability),
437
+ "classic_activation": plot_activation_heatmap(
438
+ r.classic.conv3_profile, mp, "Classic Model", r.classic.probability),
439
+ "v4_gradient": plot_gradient_heatmap(
440
+ r.v4.gradient_attribution, mp, "V4 Model"),
441
+ "splice_gradient": plot_gradient_heatmap(
442
+ r.splice.gradient_attribution, mp, "Splice Model"),
443
+ "counterfactual": plot_counterfactual(
444
+ r.splice.counterfactual_table,
445
+ r.splice.probability,
446
+ r.splice.counterfactual_delta),
447
+ "ablation": plot_ablation(r.splice.ablation),
448
+ }
449
+
450
+
451
+ def _write_json_file(json_str: str) -> str:
452
+ tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False,
453
+ mode="w", encoding="utf-8")
454
+ tmp.write(json_str)
455
+ tmp.close()
456
+ return tmp.name
457
+
458
+
459
+ def _build_summary_md(r: DecisionResult, demo_banner: str) -> str:
460
+ mech_icon = {
461
+ "Splice-driven": "🔀",
462
+ "Protein-driven": "🧬",
463
+ "Consensus": "✅",
464
+ "Ambiguous": "⚠️",
465
+ }.get(r.dominant_mechanism, "❓")
466
+
467
+ tier_icon = {
468
+ "PATHOGENIC": "🔴",
469
+ "LIKELY PATHOGENIC": "🟠",
470
+ "POSSIBLY PATHOGENIC": "🟡",
471
+ "LIKELY BENIGN": "🟢",
472
+ "BENIGN": "🟢",
473
+ }.get(r.risk_tier, "⚪")
474
+
475
+ conf_icon = {"High": "🔵", "Moderate": "🟡", "Low": "🔴"}.get(r.confidence, "⚪")
476
+
477
+ return f"""{demo_banner}
478
+ ## {tier_icon} Risk Tier: **{r.risk_tier}**
479
 
480
  | Field | Value |
481
  |---|---|
482
+ | **Variant** | `chr{r.chrom}:g.{r.pos}{r.ref}>{r.alt}` |
483
+ | **Unified Probability** | `{r.unified_probability:.4f}` |
484
+ | **Dominant Mechanism** | {mech_icon} {r.dominant_mechanism} |
485
+ | **Confidence** | {conf_icon} {r.confidence} |
486
+ | **Splice Model** | `{r.splice.probability:.4f}` — {r.splice.risk_tier} |
487
+ | **V4 Model** | `{r.v4.probability:.4f}` |
488
+ | **Classic Model** | `{r.classic.probability:.4f}` |
489
 
490
  ---
491
 
492
+ ### Explainability Metrics
493
 
494
+ | Metric | Value |
495
+ |---|---|
496
+ | **Mutation Peak Ratio** | `{r.xai.mutation_peak_ratio:.4f}` |
497
+ | **Counterfactual Magnitude** | `{r.xai.counterfactual_magnitude:.4f}` |
498
+ | **Cross-Model Locality** | `{r.xai.cross_model_locality_score:.4f}` |
499
+ | **Signal Concentration** | `{r.xai.signal_concentration_index:.4f}` |
500
+ | **XAI Strength Score** | `{r.xai.explainability_strength:.4f}` |
501
+ | **Activation Pattern** | `{r.xai.activation_pattern_type}` |
502
+ | **Model Agreement** | `{r.xai.model_agreement:.4f}` |
503
 
504
  ---
505
 
506
+ ### Interpretation Briefs
507
 
508
+ **Splice:** {r.splice_analysis[:300]}{'…' if len(r.splice_analysis)>300 else ''}
 
 
 
 
 
 
509
 
510
+ **Protein:** {r.protein_analysis[:250]}{'…' if len(r.protein_analysis)>250 else ''}
511
 
512
+ **Agreement:** {r.agreement_analysis[:250]}{'…' if len(r.agreement_analysis)>250 else ''}
 
 
 
 
 
 
 
 
513
  """
514
 
 
515
 
516
+ def _error(msg: str):
517
+ empties = [None] * 9
518
  return (
519
+ f"❌ **Error**\n\n{msg}",
520
+ "", empty, empty, empty, empty, empty,
521
+ empty, empty, empty, empty,
522
+ "{}", None,
 
 
 
 
 
 
 
 
 
523
  )
524
 
525
 
 
528
  # ═══════════════════════════════════════════════════════════════════════════════
529
 
530
  CSS = """
531
+ @import url('https://fonts.googleapis.com/css2?family