#!/usr/bin/env python3 """ Analysis and visualization module for the MAGI Gradio app. Adapted from elegant_solution.py for the app. Provides: - Impact score computation (top-K mean absolute delta) - Feature summarization with direction labels - Fingerprint-style plots for ranked signals - BigWig track description mapping Usage: from analysis import compute_impact_scores, make_fingerprint_plot df = compute_impact_scores(df) fig = make_fingerprint_plot(row, bed_names, bw_names, metadata_df) """ from dataclasses import dataclass from pathlib import Path from typing import List, Tuple, Optional, Dict, Any import numpy as np import pandas as pd import matplotlib.pyplot as plt import matplotlib # Use non-interactive backend for server deployment matplotlib.use("Agg") BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" MAGI_BASELINE_FILE = DATA_DIR / "magi_baseline_stats.csv" _MAGI_BASELINE_CACHE: Optional[Dict[str, Dict[str, pd.Series]]] = None MLM_SIGNAL_SPECS = [ ("LLR", "LLR", True), ("MLM_logprob_delta", "Log-prob Δ", True), ("MLM_Delta", "MLM Δ", True), ("MLM_KL_mean", "KL mean", False), ("MLM_KL_max", "KL max", False), ("EMB_cosine_dist", "Embedding cosine dist", False), ("EMB_l2_dist", "Embedding L2 dist", False), ("EMB_max_pos_dist", "Embedding max-pos dist", False), ("EMB_mean_pos_dist", "Embedding mean-pos dist", False), ] # ============================================================================ # DATA STRUCTURES # ============================================================================ @dataclass class MechanisticSummary: """Concise summary of top disrupted features for a variant.""" variant_id: str gene_name: str region_class: str top_features: List[Tuple[str, float, str]] # (name, delta, GOF/LOF) impact_score_bed: float llr: float description: Optional[str] = None # ============================================================================ # IMPACT SCORING # ============================================================================ def identify_delta_columns(df: pd.DataFrame) -> Tuple[List[str], List[str]]: """ Identify BED and BigWig delta columns in DataFrame. Returns: (bed_delta_cols, bw_delta_cols) """ delta_bed_cols = [c for c in df.columns if c.startswith("D_BED_")] delta_bw_cols = [c for c in df.columns if c.startswith("D_BW_")] return delta_bed_cols, delta_bw_cols def compute_impact_scores( df: pd.DataFrame, bed_k: int = 3, bw_k: int = 10, magi_baseline: Optional[Dict[str, Dict[str, pd.Series]]] = None, ) -> pd.DataFrame: """ Compute impact scores from delta columns. Impact_Score_BED: mean of top-K largest absolute BED deltas Impact_Score_BW: mean of top-K largest absolute BigWig deltas Args: df: DataFrame with D_BED_* and D_BW_* columns bed_k: Number of top BED deltas to average bw_k: Number of top BigWig deltas to average Returns: DataFrame with added Impact_Score_BED, Impact_Score_BW, and Global_z_sum_log columns """ df = df.copy() bed_cols, bw_cols = identify_delta_columns(df) # BED impact — use argpartition for O(n) top-K selection instead of O(n log n) sort if bed_cols: bed_matrix = np.abs(df[bed_cols].to_numpy()) k_bed = min(bed_k, bed_matrix.shape[1]) if k_bed > 0: # argpartition gives us indices that partition the array at position k # We want the k largest elements, so we partition at (n - k) to get them on the right indices = np.argpartition(bed_matrix, kth=-k_bed, axis=1)[:, -k_bed:] top_abs_bed = np.take_along_axis(bed_matrix, indices, axis=1) df["Impact_Score_BED"] = top_abs_bed.mean(axis=1) else: df["Impact_Score_BED"] = np.nan else: df["Impact_Score_BED"] = np.nan # BigWig impact — use argpartition for O(n) top-K selection if bw_cols: bw_matrix = np.abs(df[bw_cols].to_numpy()) k_bw = min(bw_k, bw_matrix.shape[1]) if k_bw > 0: indices = np.argpartition(bw_matrix, kth=-k_bw, axis=1)[:, -k_bw:] top_abs_bw = np.take_along_axis(bw_matrix, indices, axis=1) df["Impact_Score_BW"] = top_abs_bw.mean(axis=1) else: df["Impact_Score_BW"] = np.nan else: df["Impact_Score_BW"] = np.nan baseline = magi_baseline if magi_baseline is not None else load_magi_baseline() if baseline: df["Global_z_sum_log"] = df.apply( lambda row: compute_global_z_sum_log(row, baseline), axis=1 ) else: df["Global_z_sum_log"] = np.nan return df def _normalize_variant_type_key( value: Optional[str], row: Optional[pd.Series] = None, ) -> str: """Normalize variant-type labels for MAGI baseline lookup.""" text = ( str(value).strip().lower() if value is not None and str(value).strip() else "" ) if text in {"snp", "snv", "single nucleotide variant"}: return "snp" if text in {"deletion", "del"} or ("deletion" in text and "insert" not in text): return "deletion" if text in {"insertion", "ins"} or "insertion" in text: return "insertion" if text in {"indel", "delins"} or "delins" in text: return "indel" if row is not None: ref = str(row.get("ref", "") or "") alt = str(row.get("alt", "") or "") if len(ref) == 1 and len(alt) == 1: return "snp" if len(ref) > len(alt): return "deletion" if len(alt) > len(ref): return "insertion" indel_size = row.get("indel_size", np.nan) if pd.notna(indel_size) and float(indel_size) != 0: return "indel" return "indel" def load_magi_baseline( path: Path = MAGI_BASELINE_FILE, ) -> Dict[str, Dict[str, pd.Series]]: """Load cached benign baseline stats used for MAGI z-scoring.""" global _MAGI_BASELINE_CACHE if _MAGI_BASELINE_CACHE is not None: return _MAGI_BASELINE_CACHE if not path.exists(): _MAGI_BASELINE_CACHE = {} return _MAGI_BASELINE_CACHE stats_df = pd.read_csv(path) baseline: Dict[str, Dict[str, pd.Series]] = {} for variant_type, subset in stats_df.groupby("variant_type"): key = str(variant_type).strip().lower() baseline[key] = { "mean": pd.Series( subset["mean"].astype(float).to_numpy(), index=subset["delta_col"].astype(str), ), "std": pd.Series( subset["std"].astype(float).to_numpy(), index=subset["delta_col"].astype(str), ), } _MAGI_BASELINE_CACHE = baseline return baseline def compute_global_z_sum_log( row: pd.Series, baseline: Optional[Dict[str, Dict[str, pd.Series]]] = None, variant_type: Optional[str] = None, ) -> float: """Compute MAGI `Global_z_sum_log` from BED and BigWig delta columns.""" baseline = baseline if baseline is not None else load_magi_baseline() if not baseline: return np.nan variant_key = _normalize_variant_type_key( variant_type if variant_type is not None else row.get("variant_type"), row=row, ) lookup_order = [variant_key] if variant_key != "snp": lookup_order.append("indel") stats = None for key in lookup_order: stats = baseline.get(key) if stats: break if not stats: return np.nan delta_cols = [ col for col in row.index if (col.startswith("D_BED_") or col.startswith("D_BW_")) and not pd.isna(row[col]) ] if not delta_cols: return np.nan mean_series = stats["mean"] std_series = stats["std"] present_cols = [ col for col in delta_cols if col in mean_series.index and col in std_series.index ] if not present_cols: return np.nan values = np.asarray([float(row[col]) for col in present_cols], dtype=np.float64) mu = mean_series.reindex(present_cols).to_numpy(dtype=np.float64) sigma = std_series.reindex(present_cols).to_numpy(dtype=np.float64) sigma = np.where(np.isfinite(sigma) & (sigma >= 1e-8), sigma, 1.0) z_scores = (values - mu) / sigma z_scores[~np.isfinite(z_scores)] = np.nan if not np.isfinite(z_scores).any(): return np.nan return float(np.nansum(np.log1p(np.abs(z_scores)))) def extract_top_summary_signals( row: pd.Series, ranked: List[Dict[str, Any]], min_abs_threshold: float = 0.03, max_per_source: int = 5, ) -> Dict[str, List[Dict[str, Any]]]: """Extract compact top BED, BigWig, and MLM signals for the summary card.""" summary = {"bed": [], "bigwig": [], "mlm": []} for item in ranked: if abs(float(item.get("delta", 0.0))) < min_abs_threshold: continue payload = { "label": item.get("display_name", item.get("track_id", "")), "track_id": item.get("track_id", ""), "delta": float(item.get("delta", np.nan)), "ref_val": item.get("ref_val", np.nan), "alt_val": item.get("alt_val", np.nan), } if item.get("track_type") == "BED" and len(summary["bed"]) < max_per_source: summary["bed"].append(payload) if ( item.get("track_type") == "BigWig" and len(summary["bigwig"]) < max_per_source ): summary["bigwig"].append(payload) if ( len(summary["bed"]) >= max_per_source and len(summary["bigwig"]) >= max_per_source ): break mlm_items: List[Dict[str, Any]] = [] for column, label, signed in MLM_SIGNAL_SPECS: if column not in row.index or pd.isna(row[column]): continue value = float(row[column]) magnitude = abs(value) if magnitude < min_abs_threshold: continue mlm_items.append( { "label": label, "column": column, "value": value, "magnitude": magnitude, "signed": signed, } ) mlm_items.sort(key=lambda item: item["magnitude"], reverse=True) summary["mlm"] = mlm_items[:max_per_source] return summary def get_top_features( row: pd.Series, bed_cols: List[str], bw_cols: Optional[List[str]] = None, k: int = 10, ) -> List[Tuple[str, float, str]]: """ Extract top K disrupted features for a variant. Args: row: DataFrame row with delta columns bed_cols: BED delta column names bw_cols: BigWig delta column names (optional) k: Number of features to return Returns: List of (feature_name, delta, direction) tuples direction is 'GOF' (gain) or 'LOF' (loss) """ deltas = {} # Accumulate BED deltas for col in bed_cols: if col in row.index and not pd.isna(row[col]): name = col.replace("D_BED_", "") deltas[name] = float(row[col]) # Accumulate BigWig deltas (truncate long names) if bw_cols: for col in bw_cols: if col in row.index and not pd.isna(row[col]): name = col.replace("D_BW_", "") if len(name) > 40: name = name[:37] + "..." deltas[name] = float(row[col]) # Sort by absolute value top_items = sorted(deltas.items(), key=lambda x: abs(x[1]), reverse=True)[:k] # Add direction result = [] for feat_name, delta_val in top_items: direction = "GOF" if delta_val > 0 else "LOF" result.append((feat_name, delta_val, direction)) return result def summarize_variant( row: pd.Series, bed_cols: List[str], bw_cols: Optional[List[str]] = None, k: int = 5 ) -> MechanisticSummary: """ Create a mechanistic summary for a variant. Args: row: DataFrame row bed_cols: BED delta column names bw_cols: BigWig delta column names k: Number of top features Returns: MechanisticSummary object """ top_features = get_top_features(row, bed_cols, bw_cols, k=k) variant_id = f"{row.get('chrom', 'chr?')}:{row.get('pos', '?')} {row.get('ref', '?')}>{row.get('alt', '?')}" gene_name = row.get("gene_name", "") region_class = row.get("region_class", "OTHER") impact_bed = row.get("Impact_Score_BED", np.nan) llr = row.get("LLR", np.nan) return MechanisticSummary( variant_id=variant_id, gene_name=gene_name, region_class=region_class, top_features=top_features, impact_score_bed=impact_bed, llr=llr, ) # ============================================================================ # BIGWIG DESCRIPTION MAPPING # ============================================================================ def get_top_bigwig_descriptions( row: pd.Series, metadata_df: pd.DataFrame, k: int = 5 ) -> List[Tuple[str, float, str]]: """ Get top BigWig track changes with human-readable descriptions. Args: row: DataFrame row with D_BW_* columns metadata_df: Track metadata with columns [file_id, tissue, assay, experiment_target] k: Number of tracks to return Returns: List of (description, delta, direction) tuples description format: "{tissue} | {assay} | {target}" """ bw_cols = [c for c in row.index if c.startswith("D_BW_")] deltas = {} for col in bw_cols: if not pd.isna(row[col]): track_id = col.replace("D_BW_", "") delta = float(row[col]) # Look up metadata meta = metadata_df[metadata_df["file_id"] == track_id] if not meta.empty: r = meta.iloc[0] tissue = r.get("tissue", "") assay = r.get("assay", "") target = r.get("experiment_target", "") desc = f"{tissue} | {assay}" if pd.notna(target) and str(target).strip(): desc += f" | {target}" else: desc = track_id deltas[desc] = delta # Sort by absolute value top_items = sorted(deltas.items(), key=lambda x: abs(x[1]), reverse=True)[:k] result = [] for desc, delta in top_items: direction = "Gain" if delta > 0 else "Loss" result.append((desc, delta, direction)) return result def _clean_part(s: str) -> str: """Strip whitespace, trailing/leading commas, and collapse double commas.""" s = s.strip().strip(",").strip() # collapse repeated commas with optional spaces import re s = re.sub(r",\s*,", ",", s) return s _EMPTY_TARGETS = {"", "nan", "none", "n/a", "na", "null"} def _resolve_bw_name( track_id: str, metadata_df: Optional[pd.DataFrame] = None, metadata_dict: Optional[Dict[str, Dict[str, str]]] = None, max_len: int = 55, ) -> str: """Resolve BigWig track_id → human-readable display name.""" if metadata_dict: meta = metadata_dict.get(track_id) if meta: parts = [ _clean_part(p) for p in [ meta.get("tissue", ""), meta.get("assay", ""), meta.get("target", ""), ] if _clean_part(p) and p.strip().lower() not in _EMPTY_TARGETS ] if parts: name = " | ".join(parts) return name[:max_len] if len(name) > max_len else name if metadata_df is not None: rows = metadata_df[metadata_df["file_id"] == track_id] if not rows.empty: r = rows.iloc[0] parts = [ _clean_part(str(p)) for p in [ r.get("tissue", ""), r.get("assay", ""), r.get("experiment_target", ""), ] if pd.notna(p) and _clean_part(str(p)) and str(p).strip().lower() not in _EMPTY_TARGETS ] if parts: name = " | ".join(parts) return name[:max_len] if len(name) > max_len else name return track_id[:40] def rank_top_disrupted_tracks( row: pd.Series, bed_names: List[str], bw_names: Optional[List[str]] = None, metadata_df: Optional[pd.DataFrame] = None, metadata_dict: Optional[Dict[str, Dict[str, str]]] = None, top_k: Optional[int] = None, ) -> List[Dict]: """ Single-source ranking of the most disrupted tracks across BED + BigWig. Returns an ordered list (by |delta| descending) of dicts: {track_id, display_name, delta, track_type ("BED"/"BigWig"), ref_val, alt_val} """ items: List[Dict] = [] for name in bed_names: d_col = f"D_BED_{name}" r_col = f"REF_BED_{name}" if d_col not in row.index or pd.isna(row[d_col]): continue delta = float(row[d_col]) ref_v = ( float(row[r_col]) if r_col in row.index and not pd.isna(row[r_col]) else np.nan ) items.append( { "track_id": name, "display_name": name, "delta": delta, "track_type": "BED", "ref_val": ref_v, "alt_val": ref_v + delta if not np.isnan(ref_v) else np.nan, } ) if bw_names: for track_id in bw_names: d_col = f"D_BW_{track_id}" r_col = f"REF_BW_{track_id}" if d_col not in row.index or pd.isna(row[d_col]): continue delta = float(row[d_col]) ref_v = ( float(row[r_col]) if r_col in row.index and not pd.isna(row[r_col]) else np.nan ) items.append( { "track_id": track_id, "display_name": _resolve_bw_name( track_id, metadata_df, metadata_dict ), "delta": delta, "track_type": "BigWig", "ref_val": ref_v, "alt_val": ref_v + delta if not np.isnan(ref_v) else np.nan, } ) items.sort(key=lambda x: abs(x["delta"]), reverse=True) for idx, item in enumerate(items, start=1): item["abs_delta"] = abs(item["delta"]) item["rank"] = idx if top_k is None: return items return items[:top_k] def build_top_track_table( ranked: List[Dict], max_rows: int = 15, min_rows_by_type: Optional[Dict[str, int]] = None, ) -> pd.DataFrame: """Build a single merged top-tracks table from the unified ranking.""" if not ranked: return pd.DataFrame( [ { "Track": "No disruptions detected", "Type": "", "REF": "", "ALT": "", "Δ": "", "Direction": "", } ] ) min_rows_by_type = min_rows_by_type or {"BED": 4} max_rows = max(max_rows, sum(min_rows_by_type.values())) forced_keys = set() for track_type, min_rows in min_rows_by_type.items(): count = 0 for item in ranked: if item["track_type"] != track_type: continue forced_keys.add((item["track_type"], item["track_id"])) count += 1 if count >= min_rows: break selected = [] selected_keys = set() for item in ranked: key = (item["track_type"], item["track_id"]) if key in forced_keys and key not in selected_keys: selected.append(item) selected_keys.add(key) for item in ranked: key = (item["track_type"], item["track_id"]) if key in selected_keys: continue if len(selected) >= max_rows: break selected.append(item) selected_keys.add(key) selected.sort(key=lambda item: item.get("rank", 10**9)) rows = [] for item in selected[:max_rows]: ref_v = item["ref_val"] alt_v = item["alt_val"] rows.append( { "Track": item["display_name"], "Type": item["track_type"], "REF": f"{ref_v:.4f}" if not np.isnan(ref_v) else "N/A", "ALT": f"{alt_v:.4f}" if not np.isnan(alt_v) else "N/A", "Δ": f"{item['delta']:+.4f}", "Direction": "Gain" if item["delta"] > 0 else "Loss", } ) return pd.DataFrame(rows) # ============================================================================ # VISUALIZATION # ============================================================================ def make_fingerprint_plot( ranked: List[Dict], top_k: int = 15, figsize: Tuple[float, float] = (10, 6), ) -> plt.Figure: """ Horizontal bar chart of top disrupted features. Args: ranked: Pre-ranked list from rank_top_disrupted_tracks(). top_k: Number of features to show (capped by len(ranked)). figsize: Figure size. """ items = ranked[:top_k] if not items: fig, ax = plt.subplots(figsize=figsize) ax.text( 0.5, 0.5, "No feature data available", ha="center", va="center", fontsize=14 ) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis("off") return fig names = [it["display_name"] for it in items] vals = [it["delta"] for it in items] colors = ["#d73027" if v > 0 else "#4575b4" for v in vals] fig, ax = plt.subplots(figsize=figsize) y_pos = np.arange(len(names)) ax.barh(y_pos, vals, color=colors, alpha=0.8, edgecolor="black", linewidth=0.5) ax.axvline(0, color="black", linewidth=1.5, linestyle="-", alpha=0.7) ax.set_yticks(y_pos) ax.set_yticklabels(names, fontsize=9) ax.set_xlabel("Δ Probability (Alt − Ref)", fontsize=11, fontweight="bold") ax.set_title( "Top Disrupted Genomic Features", fontsize=13, fontweight="bold", pad=15 ) ax.grid(True, axis="x", alpha=0.3, linestyle="--") ax.set_axisbelow(True) from matplotlib.patches import Patch legend_elements = [ Patch(facecolor="#d73027", alpha=0.8, label="Gain of Function"), Patch(facecolor="#4575b4", alpha=0.8, label="Loss of Function"), ] ax.legend(handles=legend_elements, loc="lower right", frameon=True, fontsize=9) plt.tight_layout() return fig def format_summary_table(df: pd.DataFrame) -> pd.DataFrame: """ Extract clinically relevant columns for display. Args: df: Full results DataFrame Returns: DataFrame with selected columns for display """ display_cols = ["chrom", "pos", "ref", "alt"] # Add annotation if available if "gene_name" in df.columns: display_cols.append("gene_name") if "region_class" in df.columns: display_cols.append("region_class") # Add impact scores if "Impact_Score_BED" in df.columns: display_cols.append("Impact_Score_BED") if "Impact_Score_BW" in df.columns: display_cols.append("Impact_Score_BW") if "Global_z_sum_log" in df.columns: display_cols.append("Global_z_sum_log") # Add MLM features for col in ["LLR", "MLM_KL_mean", "MLM_KL_max"]: if col in df.columns: display_cols.append(col) # Add indel size if present if "indel_size" in df.columns: display_cols.append("indel_size") # Filter to available columns available = [c for c in display_cols if c in df.columns] return df[available].copy()