Spaces:
Running
Running
perf: default CONTEXT_LEN to 16k (~2.5x faster CPU inference; NTV3_CONTEXT_LEN overrides)
38da528 verified | #!/usr/bin/env python3 | |
| """ | |
| NTv3 inference module for the MAGI Gradio app. | |
| Adapted from the top-level inference.py pipeline for web deployment. | |
| Key features: | |
| - Uses local hg38.fa via pyfaidx when available | |
| - Loads the configured NTv3 model once and caches it across requests | |
| - Returns BED/BigWig deltas together with sequence-model metrics | |
| Usage: | |
| from inference import predict_variants | |
| results_df = predict_variants(variants_df) | |
| """ | |
| import os | |
| import time | |
| import warnings | |
| from pathlib import Path | |
| from typing import Optional, Tuple, List, Any, Dict, cast | |
| from functools import lru_cache | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| import torch.nn.functional as F | |
| import requests | |
| from pyfaidx import Fasta | |
| from transformers import AutoModel, AutoTokenizer | |
| # ============================================================================ | |
| # CONFIGURATION | |
| # ============================================================================ | |
| MODEL_NAME = "InstaDeepAI/NTv3_650M_post" | |
| # Sequence window. Default 16 kb — ~2.5x faster on CPU than 32 kb with | |
| # essentially unchanged SNP scores; indel MAGI scores shift somewhat vs the | |
| # 32 kb-derived baseline (see README). Override with NTV3_CONTEXT_LEN (must be | |
| # a multiple of 128, the model's total conv downsample factor); set 32768 to | |
| # match the paper's baseline exactly. | |
| CONTEXT_LEN = int(os.environ.get("NTV3_CONTEXT_LEN", 16 * 1024)) | |
| USE_BED = True | |
| USE_BIGWIGS = True | |
| USE_KL_DIVERGENCE = True | |
| USE_EMBEDDINGS = True # Useful for indels | |
| MLM_WINDOW = 3 # ±3 positions for embedding window | |
| # Paths (relative to this file) | |
| BASE_DIR = Path(__file__).parent | |
| DATA_DIR = BASE_DIR / "data" | |
| GENOME_FILE = DATA_DIR / "hg38.fa" | |
| GENOME_GZ_FILE = DATA_DIR / "hg38.fa.gz" | |
| GENOME_2BIT_FILE = DATA_DIR / "hg38.2bit" | |
| METADATA_FILE = DATA_DIR / "functional_tracks_metadata_human.csv" | |
| UCSC_SEQUENCE_URL = "https://api.genome.ucsc.edu/getData/sequence" | |
| ENSEMBL_SEQUENCE_URL = "https://rest.ensembl.org/sequence/region" | |
| ENSEMBL_PLANTS_SEQUENCE_URL = "https://rest.plants.ensembl.org/sequence/region" | |
| FORCE_UCSC = os.environ.get("NTV3_FORCE_UCSC", "0") == "1" | |
| SUPPORTED_UI_SPECIES: Dict[str, str] = { | |
| "human": "Human", | |
| "mouse": "Mouse", | |
| "rattus_norvegicus": "Rat", | |
| "canis_lupus_familiaris": "Dog", | |
| "felis_catus": "Cat", | |
| "gallus_gallus": "Chicken", | |
| "danio_rerio": "Zebrafish", | |
| "gorilla_gorilla": "Gorilla", | |
| "macaca_nemestrina": "Pig-tailed macaque", | |
| "bison_bison_bison": "Bison", | |
| "chinchilla_lanigera": "Chinchilla", | |
| "serinus_canaria": "Canary", | |
| "salmo_trutta": "Brown trout", | |
| "tetraodon_nigroviridis": "Green spotted puffer", | |
| "amphiprion_ocellaris": "Clownfish", | |
| "arabidopsis_thaliana": "Arabidopsis (Thale cress)", | |
| "oryza_sativa": "Rice", | |
| "glycine_max": "Soybean", | |
| "gossypium_hirsutum": "Cotton", | |
| "triticum_aestivum": "Wheat", | |
| "zea_mays": "Maize", | |
| } | |
| SPECIES_TO_ENSEMBL: Dict[str, str] = { | |
| "human": "homo_sapiens", | |
| "mouse": "mus_musculus", | |
| "rattus_norvegicus": "rattus_norvegicus", | |
| "canis_lupus_familiaris": "canis_lupus_familiaris", | |
| "felis_catus": "felis_catus", | |
| "gallus_gallus": "gallus_gallus", | |
| "danio_rerio": "danio_rerio", | |
| "gorilla_gorilla": "gorilla_gorilla", | |
| "macaca_nemestrina": "macaca_nemestrina", | |
| "bison_bison_bison": "bison_bison_bison", | |
| "chinchilla_lanigera": "chinchilla_lanigera", | |
| "serinus_canaria": "serinus_canaria", | |
| "salmo_trutta": "salmo_trutta", | |
| "tetraodon_nigroviridis": "tetraodon_nigroviridis", | |
| "amphiprion_ocellaris": "amphiprion_ocellaris", | |
| "arabidopsis_thaliana": "arabidopsis_thaliana", | |
| "oryza_sativa": "oryza_sativa", | |
| "glycine_max": "glycine_max", | |
| "gossypium_hirsutum": "gossypium_hirsutum", | |
| "triticum_aestivum": "triticum_aestivum", | |
| "zea_mays": "zea_mays", | |
| } | |
| PLANT_SPECIES = { | |
| "arabidopsis_thaliana", | |
| "oryza_sativa", | |
| "glycine_max", | |
| "gossypium_hirsutum", | |
| "triticum_aestivum", | |
| "zea_mays", | |
| } | |
| # Global cache for model and genome | |
| _MODEL_CACHE: Dict[str, Any] = { | |
| "model": None, | |
| "tokenizer": None, | |
| "genome": None, | |
| "twobit": None, | |
| "bed_names": None, | |
| "bigwig_names": None, | |
| "selected_bw_indices": None, | |
| "nuc_token_map": None, | |
| "human_id": None, | |
| "species_to_token_id": None, | |
| "sequence_source": "ucsc", | |
| } | |
| # Cache for the most recent track profiles (used by tracks.py for plotting) | |
| _LAST_TRACK_PROFILES: Dict[str, Any] = {} | |
| # ============================================================================ | |
| # HELPER FUNCTIONS | |
| # ============================================================================ | |
| def _normalize_ucsc_chrom(chrom: str) -> str: | |
| """Normalize chromosome string for UCSC API (expects chr-prefixed names).""" | |
| chrom = str(chrom).strip() | |
| if not chrom: | |
| return chrom | |
| return chrom if chrom.startswith("chr") else f"chr{chrom}" | |
| def _normalize_ensembl_chrom(chrom: str) -> str: | |
| """Normalize chromosome string for Ensembl REST (expects no chr prefix).""" | |
| clean = str(chrom).strip() | |
| if clean.lower().startswith("chr"): | |
| clean = clean[3:] | |
| if clean.upper() == "M": | |
| return "MT" | |
| return clean | |
| def _fetch_ucsc_window(chrom: str, start: int, end: int) -> Optional[str]: | |
| """Fetch sequence window from UCSC API with in-session caching.""" | |
| try: | |
| response = requests.get( | |
| UCSC_SEQUENCE_URL, | |
| params={ | |
| "genome": "hg38", | |
| "chrom": chrom, | |
| "start": int(start), | |
| "end": int(end), | |
| }, | |
| timeout=30, | |
| headers={"User-Agent": "MAGI-gradio/1.0 (+https://huggingface.co/spaces)"}, | |
| ) | |
| response.raise_for_status() | |
| payload = response.json() | |
| dna = payload.get("dna", "") | |
| if not dna: | |
| print(f"⚠️ UCSC returned empty DNA for {chrom}:{start}-{end}: {payload!r}") | |
| return None | |
| return str(dna).upper() | |
| except Exception as exc: | |
| print(f"⚠️ UCSC fetch failed for {chrom}:{start}-{end}: {type(exc).__name__}: {exc}") | |
| return None | |
| def _fetch_ensembl_window( | |
| species: str, chrom: str, start: int, end: int | |
| ) -> Optional[str]: | |
| """Fetch sequence window from Ensembl REST for non-human species. | |
| For plant species, first tries Ensembl REST, then falls back to Ensembl Plants REST. | |
| """ | |
| ensembl_species = SPECIES_TO_ENSEMBL.get(species) | |
| if not ensembl_species: | |
| return None | |
| clean_chrom = _normalize_ensembl_chrom(chrom) | |
| if not clean_chrom: | |
| return None | |
| start_1based = max(1, int(start) + 1) | |
| end_1based = max(start_1based, int(end)) | |
| is_plant = species in PLANT_SPECIES | |
| urls = [] | |
| if is_plant: | |
| urls.append( | |
| f"{ENSEMBL_PLANTS_SEQUENCE_URL}/{ensembl_species}/" | |
| f"{clean_chrom}:{start_1based}..{end_1based}" | |
| ) | |
| urls.append( | |
| f"{ENSEMBL_SEQUENCE_URL}/{ensembl_species}/" | |
| f"{clean_chrom}:{start_1based}..{end_1based}" | |
| ) | |
| else: | |
| urls.append( | |
| f"{ENSEMBL_SEQUENCE_URL}/{ensembl_species}/" | |
| f"{clean_chrom}:{start_1based}..{end_1based}" | |
| ) | |
| last_err = None | |
| for url in urls: | |
| try: | |
| response = requests.get( | |
| url, | |
| headers={ | |
| "Content-Type": "text/plain", | |
| "Accept": "text/plain", | |
| "User-Agent": "MAGI-gradio/1.0 (+https://huggingface.co/spaces)", | |
| }, | |
| timeout=30, | |
| ) | |
| response.raise_for_status() | |
| dna = response.text.strip() | |
| if dna: | |
| return dna.upper() | |
| except Exception as exc: | |
| last_err = exc | |
| continue | |
| if last_err is not None: | |
| print( | |
| f"⚠️ Ensembl fetch failed for {species} {chrom}:{start}-{end}: " | |
| f"{type(last_err).__name__}: {last_err}" | |
| ) | |
| return None | |
| def _fetch_sequence_from_2bit( | |
| twobit, chrom: str, start: int, end: int | |
| ) -> Optional[str]: | |
| """Fetch sequence window from a py2bit handle. Tries the original chrom name first, | |
| then with/without a 'chr' prefix to handle naming variants.""" | |
| if twobit is None: | |
| return None | |
| candidates = [chrom] | |
| if chrom.startswith("chr"): | |
| candidates.append(chrom[3:]) | |
| else: | |
| candidates.append(f"chr{chrom}") | |
| chroms = twobit.chroms() | |
| for name in candidates: | |
| if name in chroms: | |
| try: | |
| return twobit.sequence(name, int(start), int(end)).upper() | |
| except Exception: | |
| return None | |
| return None | |
| def _fetch_sequence_from_local( | |
| genome: Fasta, chrom: str, start: int, end: int | |
| ) -> Optional[str]: | |
| """Fetch sequence window from local pyfaidx genome.""" | |
| query_chrom = chrom | |
| if query_chrom not in genome: | |
| query_chrom = ( | |
| query_chrom if query_chrom.startswith("chr") else f"chr{query_chrom}" | |
| ) | |
| if query_chrom not in genome: | |
| query_chrom = ( | |
| query_chrom.replace("chr", "") | |
| if "chr" in query_chrom | |
| else f"chr{query_chrom}" | |
| ) | |
| if query_chrom not in genome: | |
| return None | |
| try: | |
| return str(genome[query_chrom][start:end]).upper() | |
| except Exception: | |
| return None | |
| def get_genomic_sequence( | |
| genome: Optional[Fasta], | |
| chrom: str, | |
| pos: int, | |
| ref: str, | |
| alt: str, | |
| context_len: int = 4096, | |
| species: str = "human", | |
| ) -> Tuple[Optional[str], Optional[str], Optional[int]]: | |
| """ | |
| Extract Ref/Alt sequences centered at variant position. | |
| Returns: | |
| (ref_seq, alt_seq, variant_center_idx) or (None, None, None) on error | |
| """ | |
| variant_idx = pos - 1 # Convert to 0-based | |
| half = context_len // 2 | |
| start = max(0, variant_idx - half) | |
| end = variant_idx + half | |
| ref_seq = None | |
| if species == "human": | |
| twobit = _MODEL_CACHE.get("twobit") | |
| if twobit is not None: | |
| ref_seq = _fetch_sequence_from_2bit(twobit, str(chrom), start, end) | |
| if ref_seq is None and genome is not None: | |
| ref_seq = _fetch_sequence_from_local(genome, str(chrom), start, end) | |
| if ref_seq is None: | |
| ucsc_chrom = _normalize_ucsc_chrom(str(chrom)) | |
| ref_seq = _fetch_ucsc_window(ucsc_chrom, start, end) | |
| # Ensembl REST fallback: HF Spaces egress sometimes blocks UCSC; Ensembl | |
| # is on a different host and tends to be reachable. | |
| if ref_seq is None: | |
| ref_seq = _fetch_ensembl_window("human", str(chrom), start, end) | |
| if ref_seq is not None: | |
| print( | |
| f"✅ Sequence fetch for {chrom}:{pos} succeeded via Ensembl " | |
| f"fallback (UCSC was unreachable)." | |
| ) | |
| if ref_seq is None: | |
| print( | |
| f"❌ Sequence fetch failed for {chrom}:{pos} " | |
| f"(window {start}-{end}); local genome, UCSC API, and Ensembl " | |
| f"REST all unavailable. Returning NaN result." | |
| ) | |
| return None, None, None | |
| else: | |
| ref_seq = _fetch_ensembl_window(species, str(chrom), start, end) | |
| if ref_seq is None: | |
| print( | |
| f"❌ Sequence fetch failed for {species} {chrom}:{pos} " | |
| f"(window {start}-{end}) from Ensembl REST API. Returning NaN result." | |
| ) | |
| return None, None, None | |
| if not ref_seq: | |
| return None, None, None | |
| center = variant_idx - start | |
| center = max(0, min(center, len(ref_seq) - 1)) | |
| # Validate REF allele | |
| ref_end = min(center + len(ref), len(ref_seq)) | |
| actual_ref = ref_seq[center:ref_end] | |
| if actual_ref.upper() != ref.upper(): | |
| warnings.warn( | |
| f"REF mismatch at {chrom}:{pos}: expected '{ref}' but genome has '{actual_ref}'" | |
| ) | |
| # Build ALT sequence | |
| alt_seq = ref_seq[:center] + alt + ref_seq[ref_end:] | |
| # Crop to equal length | |
| target_len = min(len(ref_seq), len(alt_seq)) | |
| center = min(center, target_len - 1) | |
| return ref_seq[:target_len], alt_seq[:target_len], center | |
| def get_track_indices( | |
| bigwig_names: List[str], metadata_file: Path | |
| ) -> Tuple[List[int], List[str]]: | |
| """Filter BigWig tracks to functional subset using metadata.""" | |
| if not metadata_file.exists(): | |
| print(f"⚠️ Metadata not found, using all {len(bigwig_names)} tracks") | |
| return list(range(len(bigwig_names))), bigwig_names | |
| metadata = pd.read_csv(metadata_file) | |
| key_marks = { | |
| "H3K4me3", | |
| "H3K27ac", | |
| "H3K36me3", | |
| "H3K27me3", | |
| "H3K9me3", | |
| "H3K4me1", | |
| "H3K9ac", | |
| } | |
| sel_idx, sel_names = [], [] | |
| for i, tid in enumerate(bigwig_names): | |
| rows = metadata[metadata["file_id"] == tid] | |
| if rows.empty: | |
| continue | |
| r = rows.iloc[0] | |
| assay = str(r.get("assay", "")) | |
| target = str(r.get("experiment_target", "")) | |
| dataset = str(r.get("dataset", "")) | |
| keep = ( | |
| ( | |
| "ChIP" in assay | |
| and any(m in target for m in key_marks) | |
| and dataset in ("encode_v3", "geo") | |
| ) | |
| or (("ATAC" in assay or "DNase" in assay) and dataset == "encode_v3") | |
| or dataset == "fantom5" | |
| or "CAGE" in assay | |
| or dataset == "gtex" | |
| ) | |
| if keep: | |
| sel_idx.append(i) | |
| sel_names.append(tid) | |
| print(f"📊 Selected {len(sel_idx)}/{len(bigwig_names)} BigWig tracks") | |
| return sel_idx, sel_names | |
| def build_nuc_token_map(tokenizer): | |
| """Pre-compute nucleotide -> token ID mapping for LLR calculation.""" | |
| return { | |
| nuc: tokenizer(nuc, add_special_tokens=False)["input_ids"][0] | |
| for nuc in "ACGTN" | |
| if tokenizer(nuc, add_special_tokens=False)["input_ids"] | |
| } | |
| def to_track_probabilities(track_values): | |
| """ | |
| Convert NTv3 track logits to probabilities via sigmoid. | |
| BED tracks: shape (B, L', 21, 2) → extract positive class [..., 1] | |
| BigWig tracks: shape (B, L', N) → apply sigmoid directly | |
| """ | |
| if track_values is None: | |
| return None | |
| if track_values.shape[-1] == 2: # Binary classification (BED) | |
| return torch.sigmoid(track_values[..., 1]) | |
| return torch.sigmoid(track_values) | |
| def compute_mlm_features( | |
| out_ref, | |
| out_alt, | |
| ref_seq: str, | |
| alt_seq: str, | |
| idx: int, | |
| variant_center: int, | |
| ref_allele: str, | |
| alt_allele: str, | |
| nuc_token_map: dict, | |
| use_kl: bool = True, | |
| use_embeddings: bool = False, | |
| window: int = 50, | |
| ) -> dict: | |
| """ | |
| Compute MLM language model features (LLR, KL divergence, log-probs, embeddings). | |
| Returns dict with keys: | |
| - LLR, MLM_Prior, MLM_Delta (SNPs only) | |
| - MLM_KL_mean, MLM_KL_max | |
| - MLM_logprob_ref, MLM_logprob_alt, MLM_logprob_delta | |
| - REF_5mer, ALT_5mer | |
| - EMB_* (if use_embeddings=True) | |
| """ | |
| feat = {} | |
| ref_logits = out_ref.logits[idx] | |
| alt_logits = out_alt.logits[idx] | |
| seq_len = min(ref_logits.shape[0], alt_logits.shape[0]) | |
| # Determine variant span (1 for SNP, max allele length for indel) | |
| variant_span = max(1, len(ref_allele), len(alt_allele)) | |
| variant_center = int(max(0, min(variant_center, seq_len - 1))) | |
| # KL window: cover exactly the variant span | |
| kl_ws = variant_center | |
| kl_we = min(seq_len, variant_center + variant_span) | |
| # --- LLR (single-nucleotide substitutions only) --- | |
| is_snp = len(ref_allele) == 1 and len(alt_allele) == 1 | |
| if is_snp and ref_allele in nuc_token_map and alt_allele in nuc_token_map: | |
| probs = F.softmax(ref_logits[variant_center], dim=-1) | |
| rp = float(probs[nuc_token_map[ref_allele]].cpu()) | |
| ap = float(probs[nuc_token_map[alt_allele]].cpu()) | |
| feat["LLR"] = np.log(ap / (rp + 1e-10) + 1e-10) | |
| feat["MLM_Prior"] = rp | |
| feat["MLM_Delta"] = ap - rp | |
| else: | |
| feat["LLR"] = feat["MLM_Prior"] = feat["MLM_Delta"] = np.nan | |
| # --- Context k-mers --- | |
| for pf, seq in [("REF", ref_seq), ("ALT", alt_seq)]: | |
| if len(seq) >= variant_center + 3: | |
| feat[f"{pf}_5mer"] = seq[max(0, variant_center - 2) : variant_center + 3] | |
| else: | |
| feat[f"{pf}_5mer"] = "NNNNN" | |
| # --- KL divergence + log-prob --- | |
| if use_kl: | |
| if kl_we <= kl_ws: | |
| feat["MLM_KL_mean"] = np.nan | |
| feat["MLM_KL_max"] = np.nan | |
| feat["MLM_logprob_ref"] = np.nan | |
| feat["MLM_logprob_alt"] = np.nan | |
| feat["MLM_logprob_delta"] = np.nan | |
| return feat | |
| rp_w = F.softmax(ref_logits[kl_ws:kl_we], dim=-1) | |
| ap_w = F.softmax(alt_logits[kl_ws:kl_we], dim=-1) | |
| kl = F.kl_div(rp_w.log(), ap_w, reduction="none", log_target=False).sum(-1) | |
| feat["MLM_KL_mean"] = float(kl.mean().cpu()) | |
| feat["MLM_KL_max"] = float(kl.max().cpu()) | |
| # Log-probs | |
| rlp = [ | |
| float(torch.log(rp_w[p - kl_ws, nuc_token_map[ref_seq[p]]] + 1e-10).cpu()) | |
| for p in range(kl_ws, kl_we) | |
| if p < len(ref_seq) and ref_seq[p] in nuc_token_map | |
| ] | |
| alp = [ | |
| float(torch.log(ap_w[p - kl_ws, nuc_token_map[alt_seq[p]]] + 1e-10).cpu()) | |
| for p in range(kl_ws, kl_we) | |
| if p < len(alt_seq) and alt_seq[p] in nuc_token_map | |
| ] | |
| feat["MLM_logprob_ref"] = np.mean(rlp) if rlp else np.nan | |
| feat["MLM_logprob_alt"] = np.mean(alp) if alp else np.nan | |
| feat["MLM_logprob_delta"] = feat["MLM_logprob_alt"] - feat["MLM_logprob_ref"] | |
| # --- Embedding distances --- | |
| if use_embeddings: | |
| hr = getattr(out_ref, "last_hidden_state", None) | |
| ha = getattr(out_alt, "last_hidden_state", None) | |
| if hr is not None and ha is not None: | |
| ws = max(0, variant_center - window) | |
| we = min(seq_len, variant_center + window) | |
| hr, ha = hr[idx, ws:we, :], ha[idx, ws:we, :] | |
| hrm, ham = hr.mean(0), ha.mean(0) | |
| feat["EMB_cosine_dist"] = float( | |
| 1.0 - F.cosine_similarity(hrm.unsqueeze(0), ham.unsqueeze(0)).cpu() | |
| ) | |
| feat["EMB_l2_dist"] = float(torch.norm(hrm - ham, p=2).cpu()) | |
| per_pos = torch.norm(hr - ha, p=2, dim=-1) | |
| feat["EMB_max_pos_dist"] = float(per_pos.max().cpu()) | |
| feat["EMB_mean_pos_dist"] = float(per_pos.mean().cpu()) | |
| else: | |
| for k in ( | |
| "EMB_cosine_dist", | |
| "EMB_l2_dist", | |
| "EMB_max_pos_dist", | |
| "EMB_mean_pos_dist", | |
| ): | |
| feat[k] = np.nan | |
| return feat | |
| # ============================================================================ | |
| # MODEL LOADING AND CACHING | |
| # ============================================================================ | |
| def load_model_and_resources(device: str = "cuda"): | |
| """ | |
| Load model, tokenizer, genome, and metadata once at startup. | |
| Caches everything in global _MODEL_CACHE. | |
| Handles: | |
| - HF_TOKEN authentication for gated models | |
| - bf16 mixed precision when supported by GPU | |
| """ | |
| if _MODEL_CACHE["model"] is not None: | |
| return # Already loaded | |
| # Get HF token from environment (needed for gated models like NTv3) | |
| hf_token = os.environ.get("HF_TOKEN") | |
| if not hf_token: | |
| print("⚠️ HF_TOKEN not set; model loading may fail if the model is gated") | |
| print(f"🧠 Loading NTv3 model '{MODEL_NAME}' on {device}...") | |
| # Prepare bf16 kwargs if GPU supports bfloat16 | |
| bf16_kwargs = {} | |
| if device == "cuda" and torch.cuda.is_bf16_supported(): | |
| print("💾 Enabling bf16 mixed precision to reduce memory usage") | |
| bf16_kwargs = { | |
| "stem_compute_dtype": "bfloat16", | |
| "down_convolution_compute_dtype": "bfloat16", | |
| "transformer_qkvo_compute_dtype": "bfloat16", | |
| "transformer_ffn_compute_dtype": "bfloat16", | |
| "up_convolution_compute_dtype": "bfloat16", | |
| "modulation_compute_dtype": "bfloat16", | |
| } | |
| try: | |
| model = ( | |
| AutoModel.from_pretrained( | |
| MODEL_NAME, trust_remote_code=True, token=hf_token, **bf16_kwargs | |
| ) | |
| .to(device) | |
| .eval() | |
| ) | |
| tokenizer = AutoTokenizer.from_pretrained( | |
| MODEL_NAME, trust_remote_code=True, token=hf_token | |
| ) | |
| except Exception as e: | |
| error_msg = f"Failed to load model: {str(e)}" | |
| if "gated" in str(e).lower() or "401" in str(e): | |
| error_msg += "\n\nThe NTv3_650M_post model is gated. You need to:\n1. Accept the model terms at https://huggingface.co/InstaDeepAI/NTv3_650M_post\n2. Set HF_TOKEN as an environment variable with your HuggingFace API token" | |
| raise RuntimeError(error_msg) from e | |
| species_to_token_id = dict(getattr(model.config, "species_to_token_id", {}) or {}) | |
| # Extract human species ID | |
| try: | |
| human_id = model.encode_species(["human"]).item() | |
| except (AttributeError, TypeError): | |
| human_id = species_to_token_id.get("human", 6) | |
| # Extract BED/BigWig names | |
| bed_names = [] | |
| if USE_BED: | |
| for attr in ("bed_elements_names", "bed_tracks", "bed_track_labels"): | |
| if hasattr(model.config, attr): | |
| bed_names = getattr(model.config, attr) | |
| break | |
| bw_names = [] | |
| if USE_BIGWIGS and hasattr(model.config, "bigwigs_per_species"): | |
| bw_names = model.config.bigwigs_per_species.get("human", []) | |
| # Filter BigWig tracks | |
| selected_bw_indices, selected_bw_names = get_track_indices(bw_names, METADATA_FILE) | |
| # Load genome optionally (for fast local lookups); otherwise UCSC/Ensembl fallback | |
| genome = None | |
| twobit = None | |
| sequence_source = "ucsc+ensembl" | |
| twobit_path: Optional[str] = None | |
| if FORCE_UCSC: | |
| print("⚠️ NTV3_FORCE_UCSC=1 set; using UCSC API for sequence retrieval") | |
| else: | |
| # Bundled 2bit file beats network fetch | |
| if GENOME_2BIT_FILE.exists(): | |
| twobit_path = str(GENOME_2BIT_FILE) | |
| else: | |
| # Fall back to the dedicated HF dataset on first boot | |
| twobit_repo = os.environ.get("MAGI_2BIT_DATASET", "GrimSqueaker/hg38-2bit") | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| print(f"📥 Fetching hg38.2bit from dataset {twobit_repo}...") | |
| twobit_path = hf_hub_download( | |
| repo_id=twobit_repo, | |
| filename="hg38.2bit", | |
| repo_type="dataset", | |
| token=os.environ.get("HF_TOKEN"), | |
| ) | |
| print(f"✅ hg38.2bit cached at {twobit_path}") | |
| except Exception as e: | |
| print(f"⚠️ Could not fetch hg38.2bit from {twobit_repo}: {e}") | |
| if twobit_path is not None: | |
| try: | |
| import py2bit | |
| twobit = py2bit.open(twobit_path) | |
| print( | |
| f"🧬 Loaded local reference genome from {twobit_path} (2bit, " | |
| f"{len(twobit.chroms())} contigs)" | |
| ) | |
| sequence_source = "local-2bit" | |
| except Exception as e: | |
| print(f"⚠️ Failed to open {twobit_path}: {e}") | |
| twobit = None | |
| if twobit is None and GENOME_FILE.exists(): | |
| print(f"🧬 Loading local reference genome from {GENOME_FILE}...") | |
| genome = Fasta(str(GENOME_FILE)) | |
| sequence_source = "local-fa" | |
| elif twobit is None and GENOME_GZ_FILE.exists(): | |
| print( | |
| f"⚠️ Found compressed genome at {GENOME_GZ_FILE}; using UCSC/Ensembl API (decompress to enable local fast path)" | |
| ) | |
| elif twobit is None: | |
| print("⚠️ No local hg38.fa or hg38.2bit found; using UCSC/Ensembl API for sequence retrieval") | |
| # Build token map | |
| nuc_token_map = build_nuc_token_map(tokenizer) | |
| # Cache everything | |
| _MODEL_CACHE.update( | |
| { | |
| "model": model, | |
| "tokenizer": tokenizer, | |
| "genome": genome, | |
| "twobit": twobit, | |
| "bed_names": bed_names, | |
| "bigwig_names": bw_names, | |
| "selected_bw_indices": selected_bw_indices, | |
| "nuc_token_map": nuc_token_map, | |
| "human_id": human_id, | |
| "species_to_token_id": species_to_token_id, | |
| "sequence_source": sequence_source, | |
| } | |
| ) | |
| print( | |
| f"✅ Model loaded: {len(bed_names)} BED, {len(selected_bw_indices)} BigWig tracks | sequence source: {sequence_source}" | |
| ) | |
| # ============================================================================ | |
| # INFERENCE | |
| # ============================================================================ | |
| def predict_variants( | |
| df: pd.DataFrame, | |
| device: str = "cuda", | |
| species: str = "human", | |
| cache_profiles: bool = True, | |
| ) -> pd.DataFrame: | |
| """ | |
| Run NTv3 inference on variants DataFrame. | |
| Args: | |
| df: DataFrame with columns ['chrom', 'pos', 'ref', 'alt'] | |
| device: 'cuda' or 'cpu' | |
| Returns: | |
| DataFrame with original columns plus: | |
| - D_BED_* (21 BED element deltas) | |
| - REF_BED_* (21 BED element ref probabilities) | |
| - D_BW_* (filtered BigWig deltas) | |
| - REF_BW_* (filtered BigWig ref probabilities) | |
| - LLR, MLM_Prior, MLM_Delta, MLM_KL_mean, MLM_KL_max | |
| - MLM_logprob_ref, MLM_logprob_alt, MLM_logprob_delta | |
| - REF_5mer, ALT_5mer | |
| - EMB_* (if embeddings enabled) | |
| - indel_size | |
| """ | |
| # Load model if not already loaded | |
| if _MODEL_CACHE["model"] is None: | |
| load_model_and_resources(device) | |
| model = _MODEL_CACHE.get("model") | |
| tokenizer = _MODEL_CACHE.get("tokenizer") | |
| genome = cast(Optional[Fasta], _MODEL_CACHE.get("genome")) | |
| bed_names = cast(List[str], _MODEL_CACHE.get("bed_names") or []) | |
| bigwig_names = cast(List[str], _MODEL_CACHE.get("bigwig_names") or []) | |
| selected_bw_indices = cast(List[int], _MODEL_CACHE.get("selected_bw_indices") or []) | |
| nuc_token_map = cast(dict, _MODEL_CACHE.get("nuc_token_map") or {}) | |
| species_to_token_id = cast( | |
| Dict[str, int], _MODEL_CACHE.get("species_to_token_id") or {} | |
| ) | |
| species = str(species).strip() | |
| species_id = species_to_token_id.get(species) | |
| active_bigwig_names = bigwig_names if species == "human" else [] | |
| active_bw_indices = selected_bw_indices if species == "human" else [] | |
| if model is None or tokenizer is None or species_id is None: | |
| raise RuntimeError("Model resources are not initialized correctly") | |
| # Validate input | |
| required_cols = {"chrom", "pos", "ref", "alt"} | |
| missing = required_cols - set(df.columns) | |
| if missing: | |
| raise ValueError(f"Missing required columns: {missing}") | |
| # Clean data | |
| df = df.copy() | |
| df = df[df["ref"].notna() & df["alt"].notna()].reset_index(drop=True) | |
| results = [] | |
| for idx, row in df.iterrows(): | |
| # Get sequences | |
| ref_seq, alt_seq, vcenter = get_genomic_sequence( | |
| genome, | |
| row["chrom"], | |
| row["pos"], | |
| row["ref"], | |
| row["alt"], | |
| CONTEXT_LEN, | |
| species=species, | |
| ) | |
| if ref_seq is None or alt_seq is None or vcenter is None: | |
| # Failed to fetch sequence - return NaN results with a flag | |
| # that the UI layer can surface to the user. | |
| res = {c: row[c] for c in df.columns} | |
| res["indel_size"] = len(str(row["alt"])) - len(str(row["ref"])) | |
| res["fetch_error"] = ( | |
| f"Could not fetch reference sequence for {row['chrom']}:{row['pos']} " | |
| f"(species={species}). The remote sequence API " | |
| f"({'UCSC' if species == 'human' else 'Ensembl'}) was unreachable " | |
| f"or returned no data." | |
| ) | |
| for nm in bed_names: | |
| res[f"REF_BED_{nm}"] = np.nan | |
| res[f"D_BED_{nm}"] = np.nan | |
| for gi in active_bw_indices: | |
| res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan | |
| res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan | |
| for k in ( | |
| "LLR", | |
| "MLM_Prior", | |
| "MLM_Delta", | |
| "MLM_KL_mean", | |
| "MLM_KL_max", | |
| "MLM_logprob_ref", | |
| "MLM_logprob_alt", | |
| "MLM_logprob_delta", | |
| "REF_5mer", | |
| "ALT_5mer", | |
| ): | |
| res[k] = np.nan if k not in ("REF_5mer", "ALT_5mer") else "NNNNN" | |
| results.append(res) | |
| continue | |
| # Tokenize | |
| tok_kw = dict( | |
| return_tensors="pt", | |
| padding="max_length", | |
| max_length=CONTEXT_LEN, | |
| truncation=True, | |
| add_special_tokens=False, | |
| pad_to_multiple_of=128, | |
| ) | |
| inp_r = tokenizer([ref_seq], **tok_kw).to(device) | |
| inp_a = tokenizer([alt_seq], **tok_kw).to(device) | |
| # Forward pass | |
| with torch.no_grad(): | |
| sp = torch.tensor([species_id], device=device) | |
| if USE_EMBEDDINGS: | |
| try: | |
| out_r = model(**inp_r, species_ids=sp, output_hidden_states=True) | |
| out_a = model(**inp_a, species_ids=sp, output_hidden_states=True) | |
| for o in (out_r, out_a): | |
| if getattr(o, "last_hidden_state", None) is None and hasattr( | |
| o, "hidden_states" | |
| ): | |
| o.last_hidden_state = o.hidden_states[-1] | |
| except TypeError: | |
| out_r = model(**inp_r, species_ids=sp) | |
| out_a = model(**inp_a, species_ids=sp) | |
| else: | |
| out_r = model(**inp_r, species_ids=sp) | |
| out_a = model(**inp_a, species_ids=sp) | |
| # Build result | |
| res = {c: row[c] for c in df.columns} | |
| ref_allele = str(row["ref"]) | |
| alt_allele = str(row["alt"]) | |
| res["indel_size"] = len(alt_allele) - len(ref_allele) | |
| variant_span = max(1, len(ref_allele), len(alt_allele)) | |
| in_len = int(inp_r["input_ids"].shape[1]) | |
| # === BED tracks === | |
| bed_r = getattr(out_r, "bed_tracks_logits", None) | |
| bed_a = getattr(out_a, "bed_tracks_logits", None) | |
| if USE_BED and bed_r is not None and bed_a is not None: | |
| bed_r_probs = to_track_probabilities(bed_r[0]) | |
| bed_a_probs = to_track_probabilities(bed_a[0]) | |
| track_len = int(bed_r_probs.shape[0]) | |
| track_start = max(0, (in_len - track_len) // 2) | |
| bed_pos = vcenter - track_start | |
| if 0 <= bed_pos < track_len: | |
| be = min(bed_pos + variant_span, track_len) | |
| br = bed_r_probs[bed_pos:be].mean(0).float().cpu().numpy() | |
| ba = bed_a_probs[bed_pos:be].mean(0).float().cpu().numpy() | |
| for j, nm in enumerate(bed_names): | |
| res[f"REF_BED_{nm}"] = float(br[j]) | |
| res[f"D_BED_{nm}"] = float(ba[j] - br[j]) | |
| else: | |
| for nm in bed_names: | |
| res[f"REF_BED_{nm}"] = np.nan | |
| res[f"D_BED_{nm}"] = np.nan | |
| else: | |
| for nm in bed_names: | |
| res[f"REF_BED_{nm}"] = np.nan | |
| res[f"D_BED_{nm}"] = np.nan | |
| # === BigWig tracks === | |
| bw_r = getattr(out_r, "bigwig_tracks_logits", None) | |
| bw_a = getattr(out_a, "bigwig_tracks_logits", None) | |
| if species == "human" and USE_BIGWIGS and bw_r is not None and bw_a is not None: | |
| bw_r_probs = to_track_probabilities(bw_r[0]) | |
| bw_a_probs = to_track_probabilities(bw_a[0]) | |
| track_len = int(bw_r_probs.shape[0]) | |
| track_start = max(0, (in_len - track_len) // 2) | |
| bw_pos = vcenter - track_start | |
| if 0 <= bw_pos < track_len: | |
| bwe = min(bw_pos + variant_span, track_len) | |
| bwr = bw_r_probs[bw_pos:bwe].mean(0).float().cpu().numpy() | |
| bwa = bw_a_probs[bw_pos:bwe].mean(0).float().cpu().numpy() | |
| for gi in active_bw_indices: | |
| res[f"REF_BW_{active_bigwig_names[gi]}"] = float(bwr[gi]) | |
| res[f"D_BW_{active_bigwig_names[gi]}"] = float(bwa[gi] - bwr[gi]) | |
| else: | |
| for gi in active_bw_indices: | |
| res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan | |
| res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan | |
| else: | |
| for gi in active_bw_indices: | |
| res[f"REF_BW_{active_bigwig_names[gi]}"] = np.nan | |
| res[f"D_BW_{active_bigwig_names[gi]}"] = np.nan | |
| # === MLM features === | |
| res.update( | |
| compute_mlm_features( | |
| out_r, | |
| out_a, | |
| ref_seq, | |
| alt_seq, | |
| 0, | |
| vcenter, | |
| ref_allele, | |
| alt_allele, | |
| nuc_token_map, | |
| use_kl=USE_KL_DIVERGENCE, | |
| use_embeddings=USE_EMBEDDINGS, | |
| window=MLM_WINDOW, | |
| ) | |
| ) | |
| # === Cache full track profiles for plotting (single-variant mode only) === | |
| if cache_profiles: | |
| _cache_track_profiles( | |
| row, | |
| vcenter, | |
| in_len, | |
| bed_names, | |
| active_bigwig_names, | |
| active_bw_indices, | |
| bed_r, | |
| bed_a, | |
| bw_r, | |
| bw_a, | |
| ) | |
| results.append(res) | |
| return pd.DataFrame(results) | |
| def _cache_track_profiles( | |
| row, | |
| vcenter, | |
| in_len, | |
| bed_names, | |
| bigwig_names, | |
| selected_bw_indices, | |
| bed_r_logits, | |
| bed_a_logits, | |
| bw_r_logits, | |
| bw_a_logits, | |
| ): | |
| """Cache the most recent variant's full track-length logit profiles for plotting.""" | |
| global _LAST_TRACK_PROFILES | |
| profiles: Dict[str, Any] = { | |
| "chrom": str(row["chrom"]), | |
| "pos": int(row["pos"]), | |
| "ref": str(row["ref"]), | |
| "alt": str(row["alt"]), | |
| "variant_center": vcenter, | |
| "input_len": in_len, | |
| "bed_names": list(bed_names), | |
| "bigwig_names": list(bigwig_names), | |
| "selected_bw_indices": list(selected_bw_indices), | |
| } | |
| # BED profiles: convert to probabilities and store as numpy (L, 21) | |
| if bed_r_logits is not None and bed_a_logits is not None: | |
| bed_ref_probs = to_track_probabilities(bed_r_logits[0]).float().cpu().numpy() | |
| bed_alt_probs = to_track_probabilities(bed_a_logits[0]).float().cpu().numpy() | |
| profiles["bed_ref"] = bed_ref_probs | |
| profiles["bed_alt"] = bed_alt_probs | |
| profiles["bed_track_len"] = bed_ref_probs.shape[0] | |
| profiles["bed_track_start"] = max(0, (in_len - bed_ref_probs.shape[0]) // 2) | |
| else: | |
| profiles["bed_ref"] = profiles["bed_alt"] = None | |
| # BigWig profiles: convert to probabilities and store as numpy (L, T) | |
| if bw_r_logits is not None and bw_a_logits is not None: | |
| bw_ref_probs = to_track_probabilities(bw_r_logits[0]).float().cpu().numpy() | |
| bw_alt_probs = to_track_probabilities(bw_a_logits[0]).float().cpu().numpy() | |
| profiles["bw_ref"] = bw_ref_probs | |
| profiles["bw_alt"] = bw_alt_probs | |
| profiles["bw_track_len"] = bw_ref_probs.shape[0] | |
| profiles["bw_track_start"] = max(0, (in_len - bw_ref_probs.shape[0]) // 2) | |
| else: | |
| profiles["bw_ref"] = profiles["bw_alt"] = None | |
| _LAST_TRACK_PROFILES = profiles | |