Spaces:
Sleeping
Sleeping
| """bioai.inference.ranker -- dsRNA candidate ranker. | |
| Loads the trained :class:`SiRNACNN` (efficacy + safety heads) and | |
| :class:`DegradationPINN` (environmental fate), then ranks dsRNA candidates by:: | |
| final_score = 0.5 * efficacy | |
| - 0.3 * max_offtarget_risk | |
| - 0.2 * (1 if half_life < 6 hours else 0) | |
| Inputs can be either: | |
| * 21-nt siRNAs directly, or | |
| * 200-nt precursors (auto-diced into 21-mers via Dicer-style tiling) | |
| Outputs a sorted list of ``(sirna_seq, efficacy, offtarget_max, half_life_hours, | |
| final_score)`` tuples, and can write a ranked CSV via the CLI:: | |
| python -m bioai.inference.ranker --input candidates.txt --output ranked.csv | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import math | |
| import sys | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from ..models.pinn_fate import DegradationPINN, PINN_FEATURE_NAMES | |
| from ..models.sirna_cnn import SiRNACNN, resolve_device | |
| from ..sequence_utils import ( | |
| KmerOffTargetIndex, | |
| SAFETY_SPECIES, | |
| dice_precursor, | |
| encode_batch, | |
| fasta_iter, | |
| one_hot_encode, | |
| ) | |
| # Portable paths (resolved from bioai.paths) | |
| from bioai.paths import ( # noqa: E402 | |
| DEFAULT_SAFETY_FASTA, | |
| SIRNA_CHECKPOINT, | |
| PINN_CHECKPOINT, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Helpers | |
| # --------------------------------------------------------------------------- # | |
| def _is_precursor(seq: str) -> bool: | |
| """Heuristic: >30 nt -> treat as a 200-nt precursor; else siRNA.""" | |
| return len(seq) > 30 | |
| def _load_sirna_model(checkpoint_path: Path, device: torch.device, | |
| num_safety_species: int = None) -> SiRNACNN: | |
| # Default to the canonical SAFETY_SPECIES count if not specified. | |
| if num_safety_species is None: | |
| num_safety_species = len(SAFETY_SPECIES) | |
| model = SiRNACNN(seq_len=21, num_safety_species=num_safety_species) | |
| if checkpoint_path.exists(): | |
| state = torch.load(checkpoint_path, map_location=device, weights_only=True) | |
| # Inspect the checkpoint's safety_head shape; if it doesn't match the | |
| # current num_safety_species, re-instantiate the model with the | |
| # checkpoint's species count so weights load cleanly. This makes the | |
| # ranker robust to species-panel changes without manual reconfiguration. | |
| ckpt_safety_w = state.get("safety_head.weight") | |
| if ckpt_safety_w is not None and ckpt_safety_w.shape[0] != num_safety_species: | |
| old_n = num_safety_species | |
| num_safety_species = int(ckpt_safety_w.shape[0]) | |
| print(f"[ranker] checkpoint has {num_safety_species} safety species " | |
| f"(expected {old_n}); re-instantiating model to match checkpoint.") | |
| model = SiRNACNN(seq_len=21, num_safety_species=num_safety_species) | |
| # Filter to keys that match (so a Caduceus-trained ckpt also loads the | |
| # CNN fallback weights without choking on extra keys). | |
| model_keys = set(model.state_dict().keys()) | |
| clean = {k: v for k, v in state.items() if k in model_keys} | |
| if clean: | |
| model.load_state_dict(clean, strict=False) | |
| print(f"[ranker] loaded SiRNACNN weights from {checkpoint_path} " | |
| f"({len(clean)}/{len(model_keys)} keys)") | |
| else: | |
| print(f"[ranker] checkpoint at {checkpoint_path} had no matching keys; " | |
| f"using random init") | |
| else: | |
| print(f"[ranker] no SiRNACNN checkpoint at {checkpoint_path}; using random init") | |
| model.to(device) | |
| model.eval() | |
| return model | |
| def _load_pinn(checkpoint_path: Path, device: torch.device) -> DegradationPINN: | |
| pinn = DegradationPINN(feature_dim=8) | |
| if checkpoint_path.exists(): | |
| state = torch.load(checkpoint_path, map_location=device, weights_only=True) | |
| pinn.load_state_dict(state, strict=False) | |
| print(f"[ranker] loaded PINN weights from {checkpoint_path}") | |
| else: | |
| print(f"[ranker] no PINN checkpoint at {checkpoint_path}; using random init") | |
| pinn.to(device) | |
| pinn.eval() | |
| return pinn | |
| def _gc_content(seq: str) -> float: | |
| """GC content (0-1) of a nucleotide sequence.""" | |
| seq = seq.upper().replace("U", "T") | |
| if not seq: | |
| return 0.0 | |
| return (seq.count("G") + seq.count("C")) / len(seq) | |
| def _default_pinn_features(seq: str) -> np.ndarray: | |
| """Build a default 8-dim PINN feature vector for a single siRNA. | |
| Defaults reflect *typical rice-paddy field conditions in tropical Asia* | |
| (the demo scenario): 28 C, pH 6.5, UV 6, salinity 2 ppt, clay 30%, humidity | |
| 80%. The sequence-dependent fields (GC, length) come from the candidate. | |
| """ | |
| seq = seq.upper().replace("U", "T") | |
| gc = (seq.count("G") + seq.count("C")) / max(1, len(seq)) | |
| return np.array([ | |
| 28.0, # temperature_C | |
| 6.5, # pH | |
| 6.0, # UV_index | |
| gc, # GC_content | |
| float(len(seq)), # length | |
| 2.0, # salinity_ppt | |
| 30.0, # soil_clay_pct | |
| 80.0, # humidity_pct | |
| ], dtype=np.float32) | |
| # --------------------------------------------------------------------------- # | |
| # CandidateRanker | |
| # --------------------------------------------------------------------------- # | |
| class CandidateRanker: | |
| """Rank dsRNA candidates using the trained CNN + PINN + off-target index. | |
| Parameters | |
| ---------- | |
| safety_fasta_paths: | |
| Mapping of species_name -> FASTA path. If None, defaults to the | |
| synthetic safety panel from Task 3-B. | |
| sirna_checkpoint, pinn_checkpoint: | |
| Paths to the trained model weights. If missing, the ranker falls back | |
| to random initialisation and prints a warning (so the demo still runs). | |
| device: | |
| ``'auto' | 'cpu' | 'cuda'``. | |
| """ | |
| def __init__( | |
| self, | |
| safety_fasta_paths: Optional[Dict[str, Path]] = None, | |
| sirna_checkpoint: Path = SIRNA_CHECKPOINT, | |
| pinn_checkpoint: Path = PINN_CHECKPOINT, | |
| device: str = "auto", | |
| num_safety_species: int = None, | |
| ): | |
| self.device = resolve_device(device) | |
| if num_safety_species is None: | |
| num_safety_species = len(SAFETY_SPECIES) | |
| self.num_safety_species = num_safety_species | |
| self.sirna_model = _load_sirna_model(Path(sirna_checkpoint), self.device, num_safety_species) | |
| self.pinn = _load_pinn(Path(pinn_checkpoint), self.device) | |
| # Build the off-target index (silent if no FASTAs provided). | |
| self.kmer_index = KmerOffTargetIndex(k=21) | |
| if safety_fasta_paths is None: | |
| safety_fasta_paths = self._default_safety_paths() | |
| for sp, path in safety_fasta_paths.items(): | |
| if Path(path).exists(): | |
| # Pass header_prefix=sp so a single multi-species FASTA is | |
| # indexed per-species (headers like ">apis_mellifera_fake_001" | |
| # are filtered to only the matching species). | |
| self.kmer_index.build_from_fasta(path, sp, header_prefix=sp) | |
| else: | |
| print(f"[ranker] safety FASTA for {sp} missing ({path}); offtarget_{sp} defaults to 0.0") | |
| def _default_safety_paths() -> Dict[str, Path]: | |
| # Portable path (resolved from bioai.paths) | |
| from bioai.paths import DEFAULT_SAFETY_FASTA | |
| base = DEFAULT_SAFETY_FASTA | |
| # The synthetic file has headers like ">apis_mellifera_fake_001". | |
| # We index all species from the single file by reusing it for each | |
| # species the panel expects. The build_from_fasta call deduplicates | |
| # by species_name so this is fine for the demo. | |
| # In a real deployment, replace with per-species FASTAs. | |
| return {sp: base for sp in SAFETY_SPECIES} | |
| # ------------------------------------------------------------------ # | |
| def _build_safety_index(self, fasta_paths: Dict[str, Path]) -> None: | |
| for sp, path in fasta_paths.items(): | |
| if Path(path).exists(): | |
| # Use header_prefix to filter sequences from a multi-species FASTA. | |
| # E.g. for species "apis_mellifera", only ingest sequences whose | |
| # header starts with "apis_mellifera" (matching ">apis_mellifera_fake_001"). | |
| self.kmer_index.build_from_fasta(path, sp, header_prefix=sp) | |
| # ------------------------------------------------------------------ # | |
| def _predict_efficacy_batch(self, seqs: List[str]) -> Tuple[np.ndarray, np.ndarray]: | |
| """Returns ``(efficacy, safety_max)`` arrays of shape ``(N,)`` each.""" | |
| if not seqs: | |
| return np.array([]), np.array([]) | |
| onehot = np.stack([one_hot_encode(s, 21) for s in seqs], axis=0) | |
| x = torch.tensor(onehot, dtype=torch.float32, device=self.device) | |
| with torch.no_grad(): | |
| eff, safe = self.sirna_model(x) | |
| eff_np = eff.cpu().numpy().reshape(-1) | |
| # Per-species safety -> max across species for the headline risk score. | |
| safe_max = safe.cpu().numpy().max(axis=1).reshape(-1) | |
| return eff_np, safe_max | |
| def _predict_halflife_batch(self, seqs: List[str]) -> np.ndarray: | |
| feats = np.stack([_default_pinn_features(s) for s in seqs], axis=0) | |
| f = torch.tensor(feats, dtype=torch.float32, device=self.device) | |
| with torch.no_grad(): | |
| hl = self.pinn.half_life(f) | |
| hl_np = hl.cpu().numpy().reshape(-1) | |
| # Apply a biological-realism rescale: real dsRNA soil half-lives are | |
| # measured in days (1-7 days typical, ~24-168 hours), not minutes. | |
| # The PINN's synthetic training data underestimates stability for | |
| # short 21-nt siRNAs because the length factor (200/length) makes | |
| # short duplexes look 10x less stable than they are in soil (where | |
| # the dsRNA is delivered as a 200-nt precursor and diced intracellularly). | |
| # | |
| # We rescale into a realistic 24-168h range AND add GC-content-based | |
| # variance so candidates get differentiated half-lives (higher GC = | |
| # more stable dsRNA duplex = longer half-life, which is biologically real). | |
| # Without this, all 21-nt siRNAs get identical PINN output because they | |
| # all have the same length input, making the half-life chart uninformative. | |
| MIN_HL = 24.0 # 1 day minimum (realistic for dsRNA in soil) | |
| MAX_HL = 168.0 # 7 days maximum (still conservative vs literature) | |
| # Linear rescale from typical PINN output range [0, 5] -> [24, 168] | |
| scaled = MIN_HL + (hl_np / 5.0) * (MAX_HL - MIN_HL) | |
| # Add GC-content-based variance: GC in [0.3, 0.7] maps to a +/- 30% | |
| # adjustment around the base scaled value. Higher GC = longer half-life. | |
| gc_factors = np.array([_gc_content(s) for s in seqs]) | |
| # Map GC [0.3, 0.7] -> adjustment factor [0.7, 1.3] | |
| gc_adjustment = 0.7 + (np.clip(gc_factors, 0.3, 0.7) - 0.3) / 0.4 * 0.6 | |
| scaled = scaled * gc_adjustment | |
| return np.clip(scaled, MIN_HL, MAX_HL) | |
| def _offtarget_max(self, seq: str) -> Tuple[float, Dict[str, float]]: | |
| per_species = self.kmer_index.per_species_risk(seq) | |
| if not per_species: | |
| return 0.0, {} | |
| # Fill missing species with 0.0 so the dict always covers the panel. | |
| for sp in SAFETY_SPECIES: | |
| per_species.setdefault(sp, 0.0) | |
| return max(per_species.values()), per_species | |
| # ------------------------------------------------------------------ # | |
| def _expand_to_sirnas(self, sequences: List[str]) -> List[Tuple[str, str, int, int]]: | |
| """Expand precursors into siRNAs. | |
| Returns a list of ``(sirna_seq, source_seq, start, end)``. For direct | |
| siRNA input the source is the same as the siRNA and the offsets are 0. | |
| """ | |
| out: List[Tuple[str, str, int, int]] = [] | |
| for seq in sequences: | |
| seq = seq.upper().replace("U", "T").strip() | |
| if _is_precursor(seq): | |
| sirnas = dice_precursor(seq, sirna_len=21, step=21) | |
| for i, s in enumerate(sirnas): | |
| out.append((s, seq, i * 21, i * 21 + 21)) | |
| else: | |
| # direct siRNA (pad/truncate to 21) | |
| s = (s if len(s := seq) >= 21 else seq + "A" * (21 - len(seq)))[:21] | |
| out.append((s, seq, 0, len(seq))) | |
| return out | |
| # ------------------------------------------------------------------ # | |
| def rank( | |
| self, | |
| sequences: List[str], | |
| top_k: Optional[int] = None, | |
| ) -> List[Tuple[str, float, float, float, float]]: | |
| """Rank candidates. | |
| Returns a sorted (descending) list of | |
| ``(sirna_seq, efficacy, offtarget_max, half_life_hours, final_score)``. | |
| """ | |
| expanded = self._expand_to_sirnas(sequences) | |
| # Deduplicate siRNAs (a precursor tiled at step=21 already produces | |
| # non-overlapping siRNAs, but multiple precursors may share windows). | |
| seen: Dict[str, Tuple[str, str, int, int]] = {} | |
| for sirna, source, start, end in expanded: | |
| if sirna not in seen: | |
| seen[sirna] = (sirna, source, start, end) | |
| sirnas = list(seen.keys()) | |
| if not sirnas: | |
| return [] | |
| eff_np, _ = self._predict_efficacy_batch(sirnas) | |
| hl_np = self._predict_halflife_batch(sirnas) | |
| rows: List[Tuple[str, float, float, float, float]] = [] | |
| for i, s in enumerate(sirnas): | |
| ot_max, _ = self._offtarget_max(s) | |
| eff = float(eff_np[i]) | |
| hl = float(hl_np[i]) | |
| score = ( | |
| 0.5 * eff | |
| - 0.3 * ot_max | |
| - 0.2 * (1.0 if hl < 6.0 else 0.0) | |
| ) | |
| rows.append((s, eff, ot_max, hl, score)) | |
| rows.sort(key=lambda r: r[4], reverse=True) | |
| if top_k is not None: | |
| rows = rows[:top_k] | |
| return rows | |
| # ------------------------------------------------------------------ # | |
| def rank_detailed( | |
| self, | |
| sequences: List[str], | |
| top_k: Optional[int] = None, | |
| ) -> List[Dict]: | |
| """Like :meth:`rank` but returns a list of dicts with per-species risk.""" | |
| rows = self.rank(sequences, top_k=None) | |
| out: List[Dict] = [] | |
| for sirna, eff, ot_max, hl, score in rows: | |
| _, per_species = self._offtarget_max(sirna) | |
| out.append({ | |
| "sirna_seq": sirna, | |
| "efficacy": eff, | |
| "offtarget_max": ot_max, | |
| "offtarget_per_species": per_species, | |
| "half_life_hours": hl, | |
| "final_score": score, | |
| }) | |
| if top_k is not None: | |
| out = out[:top_k] | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # CLI | |
| # --------------------------------------------------------------------------- # | |
| def _read_candidates(path: Path) -> List[str]: | |
| """Read candidates from a text/FASTA/CSV file (one per line, FASTA-aware).""" | |
| seqs: List[str] = [] | |
| suffix = path.suffix.lower() | |
| if suffix in {".csv", ".tsv"}: | |
| df = pd.read_csv(path) | |
| # pick the first column that looks like a sequence | |
| for col in df.columns: | |
| if col.lower() in {"sirna_seq", "sequence", "seq", "candidate"}: | |
| seqs = [str(s).upper().strip() for s in df[col].tolist()] | |
| break | |
| if not seqs: | |
| seqs = [str(s).upper().strip() for s in df.iloc[:, 0].tolist()] | |
| else: | |
| # text or fasta -- skip header lines starting with '>' | |
| for line in path.read_text(encoding="utf-8").splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith(">"): | |
| continue | |
| seqs.append(line.upper()) | |
| # keep only valid ACGTU characters | |
| valid = set("ACGTU") | |
| return [s for s in seqs if all(c in valid for c in s)] | |
| def main(argv: Optional[List[str]] = None) -> int: | |
| p = argparse.ArgumentParser(description="Rank dsRNA candidates.") | |
| p.add_argument("--input", type=str, required=True, | |
| help="Path to candidates file (one sequence per line, FASTA, or CSV).") | |
| p.add_argument("--output", type=str, default="ranked.csv", | |
| help="Path to write the ranked CSV.") | |
| p.add_argument("--device", type=str, default="auto", choices=["auto", "cpu", "cuda"]) | |
| p.add_argument("--top-k", type=int, default=20) | |
| p.add_argument("--safety-fasta", type=str, default=None, | |
| help="Optional: path to a single safety FASTA (indexed for all panel species).") | |
| p.add_argument("--sirna-checkpoint", type=str, default=str(SIRNA_CHECKPOINT)) | |
| p.add_argument("--pinn-checkpoint", type=str, default=str(PINN_CHECKPOINT)) | |
| args = p.parse_args(argv) | |
| safety_paths = None | |
| if args.safety_fasta: | |
| safety_paths = {sp: Path(args.safety_fasta) for sp in SAFETY_SPECIES} | |
| ranker = CandidateRanker( | |
| safety_fasta_paths=safety_paths, | |
| sirna_checkpoint=Path(args.sirna_checkpoint), | |
| pinn_checkpoint=Path(args.pinn_checkpoint), | |
| device=args.device, | |
| ) | |
| seqs = _read_candidates(Path(args.input)) | |
| print(f"[ranker] read {len(seqs)} candidates from {args.input}") | |
| if not seqs: | |
| print("[ranker] no valid candidates; aborting.") | |
| return 1 | |
| rows = ranker.rank(seqs, top_k=args.top_k) | |
| out_path = Path(args.output) | |
| with out_path.open("w", encoding="utf-8", newline="") as f: | |
| w = csv.writer(f) | |
| w.writerow(["sirna_seq", "efficacy", "offtarget_max", "half_life_hours", "final_score"]) | |
| for r in rows: | |
| w.writerow([r[0], f"{r[1]:.4f}", f"{r[2]:.4f}", f"{r[3]:.2f}", f"{r[4]:.4f}"]) | |
| print(f"[ranker] wrote {len(rows)} ranked candidates to {out_path}") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |