Mutation-XAI / explainability_engine.py
nileshhanotia's picture
Upload explainability_engine.py
961905e verified
Raw
History Blame
20.1 kB
"""
explainability_engine.py
========================
Extracts ALL internal explainability signals from all three models.
Signal inventory (per model, as specified):
Splice model (MutationPredictorCNN_v2):
βœ“ conv3 activation norm profile (99,)
βœ“ mutation-centered activation peak float
βœ“ splice aura distance (dist_donor, dist_acceptor)
βœ“ counterfactual delta |prob_mut - prob_ref|
βœ“ feature ablation response {splice, region, mutation} Ξ”prob
βœ“ risk tier classification
v4 model (MutationPredictorCNN_v2):
βœ“ importance head vector (256,) mutation-centered feature
βœ“ mutation-centered importance density float
βœ“ conv3 norm profile (99,)
Classic model (MutationPredictorCNN):
βœ“ importance head output float
βœ“ conv3 norm profile (99,)
Cross-model:
βœ“ mutation peak ratio
βœ“ counterfactual magnitude
βœ“ cross-model locality score
βœ“ signal concentration index
βœ“ explainability strength score (0–1)
βœ“ activation pattern type
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import torch
import numpy as np
from model_loader import (
MutationPredictorCNN_v2,
MutationPredictorCNN,
encode_for_v2,
encode_for_classic,
find_mutation_pos,
MUT_TYPES,
)
logger = logging.getLogger(__name__)
ALL_BASES = ["A", "T", "C", "G"]
# ── Splice site helpers (from live splice app β€” exact logic) ──────────────────
def compute_splice_distances(mutation_pos: int, ref_seq: str):
seq = ref_seq.upper()
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)
if mutation_pos < 0:
return None, None, None, None
d_donor = d_acceptor = nearest_d = nearest_a = None
if donors:
best = min(donors, key=lambda p: abs(mutation_pos - p))
d_donor, nearest_d = abs(mutation_pos - best), best
if acceptors:
best = min(acceptors, key=lambda p: abs(mutation_pos - p))
d_acceptor, nearest_a = abs(mutation_pos - best), best
return d_donor, d_acceptor, nearest_d, nearest_a
def classify_splice_risk(distance) -> str:
if distance is None: return "UNKNOWN"
if distance <= 2: return "CRITICAL SPLICE SITE"
if distance <= 8: return "SPLICE REGION"
return "NON-SPLICE"
def classify_risk_tier(prob: float) -> tuple[str, str]:
if prob >= 0.90: return "PATHOGENIC", "Very high confidence"
if prob >= 0.70: return "LIKELY PATHOGENIC", "High confidence"
if prob >= 0.50: return "POSSIBLY PATHOGENIC", "Moderate confidence"
if prob >= 0.20: return "LIKELY BENIGN", "Low pathogenic signal"
return "BENIGN", "Very low pathogenic signal"
# ═══════════════════════════════════════════════════════════════════════════════
# Data containers
# ═══════════════════════════════════════════════════════════════════════════════
@dataclass
class SpliceModelSignals:
probability: float
imp_score: float
region_imp: np.ndarray # (2,) exon/intron
splice_imp: np.ndarray # (3,) donor/acceptor/region
conv3_profile: np.ndarray # (99,)
mutation_peak: float
mutation_peak_ratio: float
splice_aura_donor: int | None
splice_aura_acceptor: int | None
nearest_donor_pos: int | None
nearest_acceptor_pos: int | None
splice_risk_donor: str
splice_risk_acceptor: str
risk_tier: str
risk_tier_desc: str
counterfactual_delta: float
counterfactual_table: list[dict]
ablation: dict
gradient_attribution: np.ndarray # (99,)
@dataclass
class V4ModelSignals:
probability: float
imp_score: float
region_imp: np.ndarray
splice_imp: np.ndarray
conv3_profile: np.ndarray
mutation_peak: float
mutation_peak_ratio: float
importance_vector: np.ndarray # (256,)
gradient_attribution: np.ndarray
@dataclass
class ClassicModelSignals:
probability: float
imp_score: float
conv3_profile: np.ndarray
mutation_peak: float
gradient_attribution: np.ndarray
@dataclass
class CrossModelAnalysis:
mutation_peak_ratio: float # mean of per-model peak ratios
counterfactual_magnitude: float # |prob_with_mut - prob_without_mut|
cross_model_locality_score: float # cosine similarity of conv3 profiles
signal_concentration_index: float # how focused activation is at mutation
explainability_strength: float # 0–1 composite
activation_pattern_type: str # Sharp / Broad / Flat
model_agreement: float # 1 - std of the 3 probabilities
# ═══════════════════════════════════════════════════════════════════════════════
# Per-model signal extractors
# ═══════════════════════════════════════════════════════════════════════════════
def _run_v2(model: MutationPredictorCNN_v2,
ref_seq: str, mut_seq: str,
exon_flag: int, intron_flag: int,
donor_flag: int = 0, acceptor_flag: int = 0,
region_flag: int = 0) -> tuple:
enc = encode_for_v2(ref_seq, mut_seq, exon_flag, intron_flag,
donor_flag, acceptor_flag, region_flag)
with torch.no_grad():
logit, imp, r_imp, s_imp = model(enc.unsqueeze(0))
prob = torch.sigmoid(logit).item()
return (prob,
float(imp.item()),
r_imp.squeeze(0).numpy(),
s_imp.squeeze(0).numpy(),
enc)
def extract_splice_signals(
model: MutationPredictorCNN_v2,
ref_seq: str,
mut_seq: str,
exon_flag: int = 0,
intron_flag: int = 0,
) -> SpliceModelSignals:
# ── Forward pass ──────────────────────────────────────────
prob, imp, r_imp, s_imp, enc = _run_v2(
model, ref_seq, mut_seq, exon_flag, intron_flag)
mutation_pos = find_mutation_pos(ref_seq, mut_seq)
conv3_profile = model.conv3_norm_profile() or np.zeros(99)
mut_peak = float(conv3_profile[mutation_pos]) if mutation_pos >= 0 else 0.0
peak_ratio = float(mut_peak / (conv3_profile.mean() + 1e-9))
# ── Splice distances ───────────────────────────────────────
d_don, d_acc, n_don, n_acc = compute_splice_distances(mutation_pos, ref_seq)
risk_don = classify_splice_risk(d_don)
risk_acc = classify_splice_risk(d_acc)
tier, tier_desc = classify_risk_tier(prob)
# ── Counterfactual delta ───────────────────────────────────
cf_table, cf_delta = _counterfactual(
model, ref_seq, mut_seq, mutation_pos, exon_flag, intron_flag, prob)
# ── Feature ablation ──────────────────────────────────────
ablation = _feature_ablation(model, enc, prob)
# ── Gradient attribution ──────────────────────────────────
grad_attr = _gradient_attribution_v2(model, enc)
return SpliceModelSignals(
probability=round(prob, 4),
imp_score=round(imp, 4),
region_imp=r_imp,
splice_imp=s_imp,
conv3_profile=conv3_profile,
mutation_peak=round(mut_peak, 4),
mutation_peak_ratio=round(peak_ratio, 4),
splice_aura_donor=d_don,
splice_aura_acceptor=d_acc,
nearest_donor_pos=n_don,
nearest_acceptor_pos=n_acc,
splice_risk_donor=risk_don,
splice_risk_acceptor=risk_acc,
risk_tier=tier,
risk_tier_desc=tier_desc,
counterfactual_delta=round(cf_delta, 4),
counterfactual_table=cf_table,
ablation=ablation,
gradient_attribution=grad_attr,
)
def extract_v4_signals(
model: MutationPredictorCNN_v2,
ref_seq: str,
mut_seq: str,
exon_flag: int = 0,
intron_flag: int = 0,
) -> V4ModelSignals:
prob, imp, r_imp, s_imp, enc = _run_v2(
model, ref_seq, mut_seq, exon_flag, intron_flag)
mutation_pos = find_mutation_pos(ref_seq, mut_seq)
conv3_profile = model.conv3_norm_profile() or np.zeros(99)
mut_peak = float(conv3_profile[mutation_pos]) if mutation_pos >= 0 else 0.0
peak_ratio = float(mut_peak / (conv3_profile.mean() + 1e-9))
imp_vector = model.importance_head_vector() or np.zeros(256)
grad_attr = _gradient_attribution_v2(model, enc)
return V4ModelSignals(
probability=round(prob, 4),
imp_score=round(imp, 4),
region_imp=r_imp,
splice_imp=s_imp,
conv3_profile=conv3_profile,
mutation_peak=round(mut_peak, 4),
mutation_peak_ratio=round(peak_ratio, 4),
importance_vector=imp_vector,
gradient_attribution=grad_attr,
)
def extract_classic_signals(
model: MutationPredictorCNN,
ref_seq: str,
mut_seq: str,
) -> ClassicModelSignals:
x_tensor = encode_for_classic(ref_seq, mut_seq)
with torch.no_grad():
logit, imp = model(x_tensor)
prob = torch.sigmoid(logit).item()
imp_val = float(imp.item())
mutation_pos = find_mutation_pos(ref_seq, mut_seq)
conv3_profile = model.conv3_norm_profile() or np.zeros(99)
mut_peak = float(conv3_profile[mutation_pos]) if mutation_pos >= 0 else 0.0
grad_attr = _gradient_attribution_classic(model, x_tensor.squeeze(0))
return ClassicModelSignals(
probability=round(prob, 4),
imp_score=round(imp_val, 4),
conv3_profile=conv3_profile,
mutation_peak=round(mut_peak, 4),
gradient_attribution=grad_attr,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Cross-model analysis
# ═══════════════════════════════════════════════════════════════════════════════
def compute_cross_model_analysis(
splice: SpliceModelSignals,
v4: V4ModelSignals,
classic: ClassicModelSignals,
mutation_pos: int,
) -> CrossModelAnalysis:
# ── 1. Mutation peak ratio (mean across models) ────────────
peak_ratios = [splice.mutation_peak_ratio, v4.mutation_peak_ratio]
mean_peak_ratio = float(np.mean(peak_ratios))
# ── 2. Counterfactual magnitude ───────────────────────────
cf_mag = splice.counterfactual_delta # primary signal from splice model
# ── 3. Cross-model locality score ─────────────────────────
# Cosine similarity between splice and v4 conv3 profiles
p1 = splice.conv3_profile.astype(np.float32)
p2 = v4.conv3_profile.astype(np.float32)
p3 = classic.conv3_profile.astype(np.float32)
cos_12 = _cosine(p1, p2)
cos_13 = _cosine(p1, p3)
cos_23 = _cosine(p2, p3)
locality_score = float(np.mean([cos_12, cos_13, cos_23]))
# ── 4. Signal concentration index ─────────────────────────
# What fraction of total activation is within Β±5 positions of mutation
concentration = _concentration_index(p1, mutation_pos, window=5)
# ── 5. Explainability strength score ─────────────────────
# Weighted composite: peak_ratio (norm), cf_mag, locality
pr_norm = min(mean_peak_ratio / 5.0, 1.0) # 5x mean = saturated
cf_norm = min(cf_mag / 0.4, 1.0) # 0.4 delta = saturated
xai_score = round(0.35 * pr_norm + 0.35 * cf_norm + 0.30 * locality_score, 4)
# ── 6. Activation pattern type ────────────────────────────
pattern = _activation_pattern(p1, mutation_pos)
# ── 7. Model agreement ───────────────────────────────────
probs = [splice.probability, v4.probability, classic.probability]
agreement = round(1.0 - float(np.std(probs)), 4)
return CrossModelAnalysis(
mutation_peak_ratio=round(mean_peak_ratio, 4),
counterfactual_magnitude=cf_mag,
cross_model_locality_score=round(locality_score, 4),
signal_concentration_index=round(concentration, 4),
explainability_strength=xai_score,
activation_pattern_type=pattern,
model_agreement=max(0.0, min(1.0, agreement)),
)
# ═══════════════════════════════════════════════════════════════════════════════
# Gradient attribution (exact logic from live splice app)
# ═══════════════════════════════════════════════════════════════════════════════
def _gradient_attribution_v2(model: MutationPredictorCNN_v2,
enc: torch.Tensor) -> np.ndarray:
model.eval()
leaf = enc.clone().detach()
leaf.requires_grad_(True)
logit, _, _, _ = model(leaf.unsqueeze(0))
model.zero_grad()
logit.backward()
grad = leaf.grad # (1106,)
seq_grad = grad[:1089].view(99, 11)
attr = seq_grad.abs().norm(dim=1).detach().numpy()
mx = attr.max()
return attr / (mx + 1e-9)
def _gradient_attribution_classic(model: MutationPredictorCNN,
enc: torch.Tensor) -> np.ndarray:
model.eval()
leaf = enc.clone().detach() # (8, 99)
leaf.requires_grad_(True)
logit, _ = model(leaf.unsqueeze(0))
model.zero_grad()
logit.backward()
grad = leaf.grad # (8, 99)
attr = grad.abs().norm(dim=0).detach().numpy()
mx = attr.max()
return attr / (mx + 1e-9)
# ═══════════════════════════════════════════════════════════════════════════════
# Counterfactual analysis (from live splice app β€” exact logic preserved)
# ═══════════════════════════════════════════════════════════════════════════════
def _counterfactual(model: MutationPredictorCNN_v2,
ref_seq: str, mut_seq: str,
mutation_pos: int,
exon_flag: int, intron_flag: int,
orig_prob: float) -> tuple[list[dict], float]:
if mutation_pos < 0 or mutation_pos >= len(ref_seq):
return [], 0.0
ref_base = ref_seq[mutation_pos].upper()
results = []
for alt in ALL_BASES:
if alt == ref_base:
continue
alt_seq = ref_seq[:mutation_pos] + alt + ref_seq[mutation_pos+1:]
enc_cf = encode_for_v2(ref_seq, alt_seq, exon_flag, intron_flag)
with torch.no_grad():
logit, _, _, _ = model(enc_cf.unsqueeze(0))
p = torch.sigmoid(logit).item()
results.append({"mutation": f"{ref_base}>{alt}", "alt_base": alt,
"probability": round(p, 4)})
results_sorted = sorted(results, key=lambda x: x["probability"], reverse=True)
all_probs = [r["probability"] for r in results] + [orig_prob]
cf_delta = round(max(all_probs) - min(all_probs), 4)
return results_sorted, cf_delta
# ═══════════════════════════════════════════════════════════════════════════════
# Feature ablation (from live splice app β€” exact logic preserved)
# ═══════════════════════════════════════════════════════════════════════════════
def _feature_ablation(model: MutationPredictorCNN_v2,
enc: torch.Tensor, base_prob: float) -> dict:
def _p(e):
with torch.no_grad():
logit, _, _, _ = model(e.unsqueeze(0))
return torch.sigmoid(logit).item()
e_no_splice = enc.clone(); e_no_splice[1103:1106] = 0.0
e_no_region = enc.clone(); e_no_region[1101:1103] = 0.0
e_no_mut = enc.clone(); e_no_mut[1089:1101] = 0.0
ds = round(abs(base_prob - _p(e_no_splice)), 4)
dr = round(abs(base_prob - _p(e_no_region)), 4)
dm = round(abs(base_prob - _p(e_no_mut)), 4)
total = ds + dr + dm + 1e-9
return {
"baseline_probability": round(base_prob, 4),
"splice_causal_effect": ds,
"region_causal_effect": dr,
"mutation_causal_effect": dm,
"splice_pct": round(ds / total * 100, 1),
"region_pct": round(dr / total * 100, 1),
"mutation_pct": round(dm / total * 100, 1),
}
# ═══════════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════════
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-9
return float(np.dot(a, b) / denom)
def _concentration_index(profile: np.ndarray, center: int, window: int = 5) -> float:
if center < 0 or len(profile) == 0:
return 0.0
lo = max(0, center - window)
hi = min(len(profile), center + window + 1)
local = profile[lo:hi].sum()
total = profile.sum() + 1e-9
return float(local / total)
def _activation_pattern(profile: np.ndarray, mutation_pos: int) -> str:
if mutation_pos < 0 or profile.max() < 1e-6:
return "Flat"
peak_val = profile[mutation_pos]
mean_val = profile.mean()
# count positions above 70% of peak
high_count = int((profile >= 0.7 * peak_val).sum())
if peak_val > 2.5 * mean_val and high_count <= 8:
return "Sharp"
if peak_val > 1.5 * mean_val:
return "Broad"
return "Flat"