nileshhanotia commited on
Commit
c6f7284
Β·
verified Β·
1 Parent(s): 894de79

Upload explainability_engine (1).py

Browse files
Files changed (1) hide show
  1. explainability_engine (1).py +556 -0
explainability_engine (1).py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ explainability_engine.py
3
+ ========================
4
+ Extract ALL internal explainability signals from each of the three models.
5
+ No signal is simplified or omitted.
6
+
7
+ Splice model signals:
8
+ - probability
9
+ - conv3 activation norm vector (99,)
10
+ - mutation-centered activation peak
11
+ - splice aura distance (donor / acceptor)
12
+ - counterfactual delta (all alternative bases)
13
+ - feature ablation response (splice / region / mutation groups)
14
+ - risk tier classification
15
+
16
+ V4 model signals:
17
+ - probability
18
+ - importance head vector (via conv3 hook β€” identical architecture)
19
+ - mutation-centered importance density
20
+
21
+ Classic model signals:
22
+ - probability
23
+ - importance head output (scalar)
24
+ - region importance (exon / intron)
25
+ - conv3 activation norm vector (99,)
26
+ """
27
+
28
+ from __future__ import annotations
29
+ import logging
30
+ from dataclasses import dataclass, field
31
+ from typing import Optional
32
+
33
+ import numpy as np
34
+ import torch
35
+
36
+ from model_loader import (
37
+ MutationPredictorCNN_v2,
38
+ MutationPredictorCNN_v4,
39
+ MutationPredictorClassic,
40
+ ModelRegistry,
41
+ encode_for_v2,
42
+ encode_for_v4,
43
+ find_mutation_pos,
44
+ ALL_BASES,
45
+ MUT_TYPES,
46
+ )
47
+
48
+ logger = logging.getLogger("mutation_xai.xai")
49
+
50
+
51
+ # ═══════════════════════════════════════════════════════════════════════════════
52
+ # Shared helpers
53
+ # ═══════════════════════════════════════════════════════════════════════════════
54
+
55
+ def _conv3_activation_norm(model: torch.nn.Module, x: torch.Tensor,
56
+ forward_fn) -> np.ndarray:
57
+ """
58
+ Register a forward hook on model.conv3, run forward_fn(x), return
59
+ L2-normalised per-position activation norm vector of shape (99,).
60
+ """
61
+ activations: dict = {}
62
+
63
+ def _hook(module, inp, out):
64
+ activations["conv3"] = out.detach()
65
+
66
+ hook = model.conv3.register_forward_hook(_hook)
67
+ try:
68
+ with torch.no_grad():
69
+ forward_fn(x)
70
+ finally:
71
+ hook.remove()
72
+
73
+ act = activations.get("conv3")
74
+ if act is None:
75
+ return np.zeros(99)
76
+
77
+ # act shape: (1, 256, 99)
78
+ norm = act.squeeze(0).norm(dim=0).numpy() # (99,)
79
+ if norm.max() > 0:
80
+ norm = norm / norm.max()
81
+ return norm
82
+
83
+
84
+ def _gradient_attribution(model: torch.nn.Module, enc: torch.Tensor,
85
+ forward_fn_grad) -> np.ndarray:
86
+ """
87
+ Compute input-gradient attribution for the sequence portion.
88
+ Returns normalised per-position attribution of shape (99,).
89
+ """
90
+ model.eval()
91
+ enc_leaf = enc.clone().detach().requires_grad_(True)
92
+ logit = forward_fn_grad(enc_leaf)
93
+ model.zero_grad()
94
+ logit.backward()
95
+ grad = enc_leaf.grad
96
+ if grad is None:
97
+ return np.zeros(99)
98
+ seq_grad = grad[:1089].view(99, 11)
99
+ attr = seq_grad.abs().norm(dim=1).detach().numpy()
100
+ if attr.max() > 0:
101
+ attr = attr / attr.max()
102
+ return attr
103
+
104
+
105
+ def _mutation_peak_ratio(profile: np.ndarray, mutation_pos: int) -> float:
106
+ """
107
+ peak_signal / mean_signal, where peak_signal is the profile value at
108
+ mutation_pos. Returns 0.0 if mutation_pos < 0 or mean == 0.
109
+ """
110
+ if mutation_pos < 0 or mutation_pos >= len(profile):
111
+ return 0.0
112
+ mean_sig = float(profile.mean())
113
+ if mean_sig == 0:
114
+ return 0.0
115
+ return float(profile[mutation_pos]) / mean_sig
116
+
117
+
118
+ def _signal_concentration_index(profile: np.ndarray, mutation_pos: int,
119
+ window: int = 10) -> float:
120
+ """
121
+ Fraction of total activation energy within Β±window of mutation_pos.
122
+ Ranges 0–1; 1.0 = perfectly concentrated.
123
+ """
124
+ if mutation_pos < 0:
125
+ return 0.0
126
+ total = float(profile.sum())
127
+ if total == 0:
128
+ return 0.0
129
+ lo = max(0, mutation_pos - window)
130
+ hi = min(len(profile), mutation_pos + window + 1)
131
+ local = float(profile[lo:hi].sum())
132
+ return local / total
133
+
134
+
135
+ def _splice_distances(ref_seq: str, mutation_pos: int):
136
+ """
137
+ Scan ref_seq for GT (donor) and AG (acceptor) dinucleotides.
138
+ Returns (dist_donor, dist_acceptor, nearest_donor_pos, nearest_acceptor_pos).
139
+ Any value may be None if no site found.
140
+ """
141
+ seq = (ref_seq.upper() + "N" * 99)[:99]
142
+ donors, acceptors = [], []
143
+ for i in range(len(seq) - 1):
144
+ if seq[i:i+2] == "GT": donors.append(i)
145
+ if seq[i:i+2] == "AG": acceptors.append(i)
146
+
147
+ if mutation_pos < 0:
148
+ return None, None, None, None
149
+
150
+ dist_d = nearest_d = None
151
+ dist_a = nearest_a = None
152
+
153
+ if donors:
154
+ pairs = sorted([(abs(mutation_pos - p), p) for p in donors])
155
+ dist_d, nearest_d = pairs[0]
156
+ if acceptors:
157
+ pairs = sorted([(abs(mutation_pos - p), p) for p in acceptors])
158
+ dist_a, nearest_a = pairs[0]
159
+
160
+ return dist_d, dist_a, nearest_d, nearest_a
161
+
162
+
163
+ def _classify_splice_risk(distance: Optional[int]) -> str:
164
+ if distance is None: return "UNKNOWN"
165
+ if distance <= 2: return "CRITICAL SPLICE SITE"
166
+ if distance <= 8: return "SPLICE REGION"
167
+ return "NON-SPLICE"
168
+
169
+
170
+ def _classify_risk_tier(prob: float) -> tuple[str, str]:
171
+ if prob >= 0.90: return "PATHOGENIC", "Very high confidence"
172
+ if prob >= 0.70: return "LIKELY PATHOGENIC", "High confidence"
173
+ if prob >= 0.50: return "POSSIBLY PATHOGENIC", "Moderate confidence"
174
+ if prob >= 0.20: return "LIKELY BENIGN", "Low pathogenic signal"
175
+ return "BENIGN", "Very low pathogenic signal"
176
+
177
+
178
+ # ═══════════════════════════════════════════════════════════════════════════════
179
+ # Signal dataclasses
180
+ # ═══════════════════════════════════════════════════════════════════════════════
181
+
182
+ @dataclass
183
+ class SpliceSignals:
184
+ probability: float
185
+ risk_tier: str
186
+ tier_desc: str
187
+ conv3_norm: np.ndarray # (99,)
188
+ gradient_attribution: np.ndarray # (99,)
189
+ mutation_pos: int
190
+ mutation_peak_ratio: float
191
+ signal_concentration: float
192
+ imp_score: float # importance_head output
193
+ region_imp: np.ndarray # (2,) [exon, intron]
194
+ splice_imp: np.ndarray # (3,) [donor, acc, region]
195
+ dist_donor: Optional[int]
196
+ dist_acceptor: Optional[int]
197
+ nearest_donor: Optional[int]
198
+ nearest_acceptor: Optional[int]
199
+ splice_risk_donor: str
200
+ splice_risk_acceptor: str
201
+ counterfactual: dict # all-base CF results
202
+ ablation: dict # feature ablation deltas
203
+ splice_aura_score: float # proximity-weighted splice signal
204
+
205
+
206
+ @dataclass
207
+ class V4Signals:
208
+ probability: float
209
+ conv3_norm: np.ndarray # (99,)
210
+ gradient_attribution: np.ndarray # (99,)
211
+ mutation_pos: int
212
+ mutation_peak_ratio: float
213
+ signal_concentration: float
214
+
215
+
216
+ @dataclass
217
+ class ClassicSignals:
218
+ probability: float
219
+ conv3_norm: np.ndarray # (99,)
220
+ importance_head: float # scalar importance_head output
221
+ region_imp: np.ndarray # (2,) [exon, intron]
222
+ mutation_pos: int
223
+ mutation_peak_ratio: float
224
+ signal_concentration: float
225
+
226
+
227
+ # ═══════════════════════════════════════════════════════════════════════════════
228
+ # β‘  Extract Splice Signals
229
+ # ═══════════════════════════════════════════════════════════════════════════════
230
+
231
+ def extract_splice_signals(model: MutationPredictorCNN_v2,
232
+ ref_seq: str, mut_seq: str,
233
+ exon_flag: int, intron_flag: int) -> SpliceSignals:
234
+ enc = encode_for_v2(ref_seq, mut_seq, exon_flag, intron_flag)
235
+
236
+ # ── base forward pass ────────────────────────────────────────────────────
237
+ with torch.no_grad():
238
+ x = enc.unsqueeze(0)
239
+ logit, imp_t, r_imp_t, s_imp_t = model(x)
240
+ prob = float(torch.sigmoid(logit).item())
241
+ imp_score = float(imp_t.item())
242
+ region_imp= r_imp_t[0].numpy()
243
+ splice_imp= s_imp_t[0].numpy()
244
+
245
+ tier, tier_desc = _classify_risk_tier(prob)
246
+ mutation_pos = find_mutation_pos(ref_seq, mut_seq)
247
+
248
+ # ── conv3 activation norm ────────────────────────────────────────────────
249
+ def _fwd(x_in):
250
+ return model(x_in.unsqueeze(0))
251
+
252
+ conv3_norm = _conv3_activation_norm(
253
+ model, enc,
254
+ lambda x: model(x.unsqueeze(0))
255
+ )
256
+
257
+ # ── gradient attribution ─────────────────────────────────────────────────
258
+ def _fwd_grad(leaf: torch.Tensor):
259
+ logit_g, _, _, _ = model(leaf.unsqueeze(0))
260
+ return logit_g
261
+
262
+ grad_attr = _gradient_attribution(model, enc, _fwd_grad)
263
+
264
+ # ── mutation-peak derived metrics ────────��────────────────────────────────
265
+ mpr = _mutation_peak_ratio(conv3_norm, mutation_pos)
266
+ sci = _signal_concentration_index(conv3_norm, mutation_pos)
267
+
268
+ # ── splice distances ─────────────────────────────────────────────────────
269
+ dist_d, dist_a, nearest_d, nearest_a = _splice_distances(ref_seq, mutation_pos)
270
+ risk_d = _classify_splice_risk(dist_d)
271
+ risk_a = _classify_splice_risk(dist_a)
272
+
273
+ # ── splice aura score β€” proximity-weighted composite ────────────────────
274
+ def _proximity_weight(dist):
275
+ if dist is None: return 0.0
276
+ if dist <= 2: return 1.0
277
+ if dist <= 8: return 0.5
278
+ return 0.1
279
+
280
+ aura = (
281
+ _proximity_weight(dist_d) * float(splice_imp[0]) +
282
+ _proximity_weight(dist_a) * float(splice_imp[1]) +
283
+ float(splice_imp[2]) * 0.3
284
+ ) / 1.6 # normalise to ~[0,1]
285
+ aura = float(np.clip(aura, 0.0, 1.0))
286
+
287
+ # ── counterfactual analysis ───────────────────────────────────────────────
288
+ cf = _counterfactual_splice(model, ref_seq, mut_seq, mutation_pos,
289
+ exon_flag, intron_flag, prob)
290
+
291
+ # ── feature ablation ─────────────────────────────────────────────────────
292
+ abl = _ablation_splice(model, enc, prob)
293
+
294
+ return SpliceSignals(
295
+ probability=prob, risk_tier=tier, tier_desc=tier_desc,
296
+ conv3_norm=conv3_norm, gradient_attribution=grad_attr,
297
+ mutation_pos=mutation_pos,
298
+ mutation_peak_ratio=mpr, signal_concentration=sci,
299
+ imp_score=imp_score, region_imp=region_imp, splice_imp=splice_imp,
300
+ dist_donor=dist_d, dist_acceptor=dist_a,
301
+ nearest_donor=nearest_d, nearest_acceptor=nearest_a,
302
+ splice_risk_donor=risk_d, splice_risk_acceptor=risk_a,
303
+ counterfactual=cf, ablation=abl,
304
+ splice_aura_score=aura,
305
+ )
306
+
307
+
308
+ def _counterfactual_splice(model: MutationPredictorCNN_v2,
309
+ ref_seq: str, mut_seq: str,
310
+ mutation_pos: int, exon_flag: int,
311
+ intron_flag: int, orig_prob: float) -> dict:
312
+ if mutation_pos < 0 or mutation_pos >= len(ref_seq):
313
+ return {"error": "mutation position not detected",
314
+ "original_probability": orig_prob}
315
+
316
+ ref_base = ref_seq[mutation_pos].upper()
317
+ results = []
318
+
319
+ for alt in ALL_BASES:
320
+ if alt == ref_base:
321
+ continue
322
+ alt_mut = ref_seq[:mutation_pos] + alt + ref_seq[mutation_pos+1:]
323
+ enc_cf = encode_for_v2(ref_seq, alt_mut, exon_flag, intron_flag)
324
+ with torch.no_grad():
325
+ logit_cf, _, _, _ = model(enc_cf.unsqueeze(0))
326
+ p = float(torch.sigmoid(logit_cf).item())
327
+ results.append({"mutation": f"{ref_base}>{alt}", "alt_base": alt,
328
+ "probability": round(p, 4)})
329
+
330
+ all_probs = [r["probability"] for r in results] + [orig_prob]
331
+ return {
332
+ "original_probability": round(orig_prob, 4),
333
+ "ref_base": ref_base,
334
+ "table": sorted(results, key=lambda x: x["probability"], reverse=True),
335
+ "max_probability": round(max(all_probs), 4),
336
+ "min_probability": round(min(all_probs), 4),
337
+ "probability_range": round(max(all_probs) - min(all_probs), 4),
338
+ "counterfactual_delta": round(abs(max(all_probs) - min(all_probs)), 4),
339
+ }
340
+
341
+
342
+ def _ablation_splice(model: MutationPredictorCNN_v2,
343
+ enc: torch.Tensor, prob_base: float) -> dict:
344
+ def _prob(e):
345
+ with torch.no_grad():
346
+ logit, _, _, _ = model(e.unsqueeze(0))
347
+ return float(torch.sigmoid(logit).item())
348
+
349
+ enc_no_splice = enc.clone(); enc_no_splice[1103:1106] = 0.0
350
+ enc_no_region = enc.clone(); enc_no_region[1101:1103] = 0.0
351
+ enc_no_mut = enc.clone(); enc_no_mut[1089:1101] = 0.0
352
+ enc_no_seq = enc.clone(); enc_no_seq[:1089] = 0.0
353
+
354
+ d_splice = round(abs(prob_base - _prob(enc_no_splice)), 4)
355
+ d_region = round(abs(prob_base - _prob(enc_no_region)), 4)
356
+ d_mut = round(abs(prob_base - _prob(enc_no_mut)), 4)
357
+ d_seq = round(abs(prob_base - _prob(enc_no_seq)), 4)
358
+
359
+ total = d_splice + d_region + d_mut + d_seq
360
+ def _pct(v): return round(v / total * 100, 1) if total > 0 else 0.0
361
+
362
+ return {
363
+ "baseline_probability": round(prob_base, 4),
364
+ "splice_delta": d_splice, "splice_pct": _pct(d_splice),
365
+ "region_delta": d_region, "region_pct": _pct(d_region),
366
+ "mutation_delta": d_mut, "mutation_pct": _pct(d_mut),
367
+ "sequence_delta": d_seq, "sequence_pct": _pct(d_seq),
368
+ "dominant_feature": max(
369
+ [("Splice features", d_splice), ("Region flags", d_region),
370
+ ("Mutation type", d_mut), ("Sequence context", d_seq)],
371
+ key=lambda x: x[1]
372
+ )[0],
373
+ }
374
+
375
+
376
+ # ═══════════════════════════════════════════════════════════════════════════════
377
+ # β‘‘ Extract V4 Signals
378
+ # ═══════════════════════════════════════════════════════════════════════════════
379
+
380
+ def extract_v4_signals(model: MutationPredictorCNN_v4,
381
+ ref_seq: str, mut_seq: str,
382
+ exon_flag: int, intron_flag: int) -> V4Signals:
383
+ seq_t, mut_oh, region_t, splice_t = encode_for_v4(ref_seq, mut_seq,
384
+ exon_flag, intron_flag)
385
+ # ── base forward ─────────────────────────────────────────────────────────
386
+ with torch.no_grad():
387
+ logit = model(seq_t, mut_oh, region_t, splice_t)
388
+ prob = float(torch.sigmoid(logit).item())
389
+
390
+ mutation_pos = find_mutation_pos(ref_seq, mut_seq)
391
+
392
+ # ── conv3 activation norm ────────────────────────────────────────────────
393
+ def _fwd_v4(seq_in):
394
+ return model(seq_in, mut_oh, region_t, splice_t)
395
+
396
+ conv3_norm = _conv3_activation_norm(
397
+ model, seq_t.squeeze(0),
398
+ lambda x: model(x.unsqueeze(0), mut_oh, region_t, splice_t)
399
+ )
400
+
401
+ # ── gradient attribution β€” through sequence tensor only ──────────────────
402
+ model.eval()
403
+ seq_leaf = seq_t.clone().detach().requires_grad_(True)
404
+ logit_g = model(seq_leaf, mut_oh, region_t, splice_t)
405
+ model.zero_grad()
406
+ logit_g.backward()
407
+ grad = seq_leaf.grad # (1, 11, 99)
408
+ if grad is not None:
409
+ # L2 norm per position across 11 channels
410
+ grad_attr = grad.squeeze(0).abs().norm(dim=0).numpy() # (99,)
411
+ if grad_attr.max() > 0:
412
+ grad_attr = grad_attr / grad_attr.max()
413
+ else:
414
+ grad_attr = np.zeros(99)
415
+
416
+ mpr = _mutation_peak_ratio(conv3_norm, mutation_pos)
417
+ sci = _signal_concentration_index(conv3_norm, mutation_pos)
418
+
419
+ return V4Signals(
420
+ probability=prob,
421
+ conv3_norm=conv3_norm, gradient_attribution=grad_attr,
422
+ mutation_pos=mutation_pos,
423
+ mutation_peak_ratio=mpr, signal_concentration=sci,
424
+ )
425
+
426
+
427
+ # ═══════════════════════════════════════════════════════════════════════════════
428
+ # β‘’ Extract Classic Signals
429
+ # ═══════════════════════════════════════════════════════════════════════════════
430
+
431
+ def extract_classic_signals(model: MutationPredictorClassic,
432
+ ref_seq: str, mut_seq: str,
433
+ exon_flag: int, intron_flag: int) -> ClassicSignals:
434
+ enc = encode_for_v2(ref_seq, mut_seq, exon_flag, intron_flag)
435
+
436
+ # ── base forward ─────────────────────────────────────────────────────────
437
+ with torch.no_grad():
438
+ x = enc.unsqueeze(0)
439
+ logit, imp_t, r_imp_t = model(x)
440
+ prob = float(torch.sigmoid(logit).item())
441
+ imp_score = float(imp_t.item())
442
+ region_imp= r_imp_t[0].numpy()
443
+
444
+ mutation_pos = find_mutation_pos(ref_seq, mut_seq)
445
+
446
+ # ── conv3 activation norm ────────────────────────────────────────────────
447
+ conv3_norm = _conv3_activation_norm(
448
+ model, enc,
449
+ lambda x: model(x.unsqueeze(0))
450
+ )
451
+
452
+ mpr = _mutation_peak_ratio(conv3_norm, mutation_pos)
453
+ sci = _signal_concentration_index(conv3_norm, mutation_pos)
454
+
455
+ return ClassicSignals(
456
+ probability=prob,
457
+ conv3_norm=conv3_norm,
458
+ importance_head=imp_score,
459
+ region_imp=region_imp,
460
+ mutation_pos=mutation_pos,
461
+ mutation_peak_ratio=mpr,
462
+ signal_concentration=sci,
463
+ )
464
+
465
+
466
+ # ═══════════════════════════════════════════════════════════════════════════════
467
+ # Cross-model analysis
468
+ # ═══════════════════════════════════════════════════════════════════════════════
469
+
470
+ def compute_cross_model_analysis(splice: SpliceSignals,
471
+ v4: V4Signals,
472
+ classic: ClassicSignals) -> dict:
473
+ """
474
+ Compute all five XAI Engine metrics and cross-model locality score.
475
+ """
476
+
477
+ # 1. Mutation Peak Ratio β€” average across models
478
+ mpr_avg = float(np.mean([
479
+ splice.mutation_peak_ratio,
480
+ v4.mutation_peak_ratio,
481
+ classic.mutation_peak_ratio,
482
+ ]))
483
+
484
+ # 2. Counterfactual magnitude β€” from splice model (has full CF data)
485
+ cf_mag = float(splice.counterfactual.get("counterfactual_delta", 0.0))
486
+
487
+ # 3. Cross-model locality score
488
+ # Are activation peaks aligned across models?
489
+ # Compute correlation of all three conv3_norm profiles.
490
+ profiles = [splice.conv3_norm, v4.conv3_norm, classic.conv3_norm]
491
+ cors = []
492
+ for i in range(len(profiles)):
493
+ for j in range(i+1, len(profiles)):
494
+ a, b = profiles[i], profiles[j]
495
+ if a.std() > 0 and b.std() > 0:
496
+ cors.append(float(np.corrcoef(a, b)[0, 1]))
497
+ else:
498
+ cors.append(0.0)
499
+ cross_locality = float(np.clip(np.mean(cors), -1.0, 1.0))
500
+
501
+ # 4. Signal concentration index β€” average across models
502
+ sci_avg = float(np.mean([
503
+ splice.signal_concentration,
504
+ v4.signal_concentration,
505
+ classic.signal_concentration,
506
+ ]))
507
+
508
+ # 5. Explainability Strength Score (0–1)
509
+ mpr_norm = float(np.clip(mpr_avg / 3.0, 0.0, 1.0)) # >3Γ— peak = full score
510
+ cf_norm = float(np.clip(cf_mag, 0.0, 1.0))
511
+ loc_norm = float(np.clip((cross_locality + 1.0) / 2.0, 0.0, 1.0))
512
+
513
+ ess = (0.35 * mpr_norm + 0.35 * cf_norm + 0.30 * loc_norm)
514
+ ess = float(np.clip(ess, 0.0, 1.0))
515
+
516
+ # Activation pattern type
517
+ peak = float(np.max(splice.conv3_norm))
518
+ if peak > 0:
519
+ above_half = int(np.sum(splice.conv3_norm > 0.5 * peak))
520
+ above_tenth = int(np.sum(splice.conv3_norm > 0.1 * peak))
521
+ else:
522
+ above_half = above_tenth = 0
523
+
524
+ if above_half <= 5:
525
+ pattern = "Sharp"
526
+ elif above_half <= 25:
527
+ pattern = "Broad"
528
+ else:
529
+ pattern = "Flat"
530
+
531
+ # Per-model probability agreement
532
+ probs = [splice.probability, v4.probability, classic.probability]
533
+ prob_std = float(np.std(probs))
534
+
535
+ return {
536
+ "mutation_peak_ratio": round(mpr_avg, 4),
537
+ "counterfactual_magnitude": round(cf_mag, 4),
538
+ "cross_model_locality_score": round(cross_locality, 4),
539
+ "signal_concentration_index": round(sci_avg, 4),
540
+ "explainability_strength_score": round(ess, 4),
541
+ "activation_pattern_type": pattern,
542
+ "prob_std": round(prob_std, 4),
543
+ "model_agreement": _agreement_level(prob_std),
544
+ # raw profiles for plotting
545
+ "_splice_norm": splice.conv3_norm,
546
+ "_v4_norm": v4.conv3_norm,
547
+ "_classic_norm": classic.conv3_norm,
548
+ "_splice_grad": splice.gradient_attribution,
549
+ "_v4_grad": v4.gradient_attribution,
550
+ }
551
+
552
+
553
+ def _agreement_level(std: float) -> str:
554
+ if std < 0.05: return "Strong"
555
+ if std < 0.12: return "Moderate"
556
+ return "Weak"