Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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,) | |
| 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 | |
| class ClassicModelSignals: | |
| probability: float | |
| imp_score: float | |
| conv3_profile: np.ndarray | |
| mutation_peak: float | |
| gradient_attribution: np.ndarray | |
| 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" | |