Spaces:
Sleeping
Sleeping
| """bioai.sequence_utils -- shared sequence helpers (one-hot encode, FASTA IO, | |
| k-mer off-target index, dsRNA tiling). | |
| These are refactored copies of the helpers that previously lived in the | |
| broken `src/` scaffold so that the new `bioai/` package is self-contained. | |
| """ | |
| from __future__ import annotations | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from typing import Dict, Iterator, List, Set, Tuple | |
| import numpy as np | |
| # Canonical safety panel — 12 species covering all ecological roles regulators | |
| # care about: pollinators, beneficial insects, soil invertebrates, aquatic | |
| # invertebrates, livestock (mammals + poultry + fish), and human safety. | |
| # `homo_sapiens` is intentionally last so demo runs without human data still | |
| # produce a column for it (always 0.0). | |
| SAFETY_SPECIES: List[str] = [ | |
| # Pollinators | |
| "apis_mellifera", # Western honeybee | |
| "bombus_terrestris", # Buff-tailed bumblebee | |
| "megachile_rotundata", # Alfalfa leafcutter bee | |
| # Beneficial predators (biocontrol agents) | |
| "adalia_bipunctata", # Two-spot ladybird beetle | |
| "chrysoperla_carnea", # Green lacewing | |
| # Soil invertebrates | |
| "eisenia_fetida", # Red wiggler compost worm | |
| # Aquatic invertebrates | |
| "daphnia_magna", # Water flea (standard ecotox model) | |
| # Livestock & poultry | |
| "bos_taurus", # Cattle | |
| "bos_indicus", # Zebu | |
| "gallus_gallus", # Chicken | |
| "ovis_aries", # Sheep | |
| "sus_scrofa", # Pig | |
| # Aquatic vertebrate | |
| "danio_rerio", # Zebrafish | |
| # Human (applicator + consumer safety) | |
| "homo_sapiens", | |
| ] | |
| # Pest species the demo can target. The orchestrator's keyword parser maps | |
| # user input to one of these. Synthetic transcripts are generated for each. | |
| PEST_SPECIES: List[str] = [ | |
| "nilaparvata_lugens", # Brown planthopper (rice) | |
| "spodoptera_frugiperda", # Fall armyworm (maize) | |
| "schistocerca_gregaria", # Desert locust (wheat, cereals) | |
| "chilo_suppressalis", # Striped stem borer (rice) | |
| "myzus_persicae", # Peach-potato aphid (vegetables) | |
| "leptinotarsa_decemlineata", # Colorado potato beetle (potato) | |
| "bemisia_tabaci", # Tobacco whitefly (tomato, cotton) | |
| ] | |
| TARGET_SPECIES: str = "nilaparvata_lugens" | |
| # Common-name -> scientific-name mapping for pest species normalization. | |
| # The LLM (and users) may return "Brown Planthopper" or "brown planthopper" | |
| # but the FASTA headers use "NILAPARVATA_LUGENS_FAKE_001". This mapping | |
| # normalizes any of the accepted aliases to the canonical scientific name | |
| # used in the FASTA headers and the PEST_SPECIES list. | |
| PEST_SPECIES_ALIASES: Dict[str, str] = { | |
| # Brown planthopper | |
| "brown planthopper": "nilaparvata_lugens", | |
| "brown plant hopper": "nilaparvata_lugens", | |
| "nilaparvata lugens": "nilaparvata_lugens", | |
| "nilaparvata_lugens": "nilaparvata_lugens", | |
| "bph": "nilaparvata_lugens", | |
| # Fall armyworm | |
| "fall armyworm": "spodoptera_frugiperda", | |
| "fall army worm": "spodoptera_frugiperda", | |
| "spodoptera frugiperda": "spodoptera_frugiperda", | |
| "spodoptera_frugiperda": "spodoptera_frugiperda", | |
| "armyworm": "spodoptera_frugiperda", | |
| # Desert locust | |
| "desert locust": "schistocerca_gregaria", | |
| "locust": "schistocerca_gregaria", | |
| "schistocerca gregaria": "schistocerca_gregaria", | |
| "schistocerca_gregaria": "schistocerca_gregaria", | |
| # Striped stem borer | |
| "striped stem borer": "chilo_suppressalis", | |
| "stem borer": "chilo_suppressalis", | |
| "chilo suppressalis": "chilo_suppressalis", | |
| "chilo_suppressalis": "chilo_suppressalis", | |
| # Peach-potato aphid | |
| "peach-potato aphid": "myzus_persicae", | |
| "peach potato aphid": "myzus_persicae", | |
| "aphid": "myzus_persicae", | |
| "myzus persicae": "myzus_persicae", | |
| "myzus_persicae": "myzus_persicae", | |
| # Colorado potato beetle | |
| "colorado potato beetle": "leptinotarsa_decemlineata", | |
| "potato beetle": "leptinotarsa_decemlineata", | |
| "leptinotarsa decemlineata": "leptinotarsa_decemlineata", | |
| "leptinotarsa_decemlineata": "leptinotarsa_decemlineata", | |
| # Tobacco whitefly | |
| "tobacco whitefly": "bemisia_tabaci", | |
| "whitefly": "bemisia_tabaci", | |
| "white fly": "bemisia_tabaci", | |
| "bemisia tabaci": "bemisia_tabaci", | |
| "bemisia_tabaci": "bemisia_tabaci", | |
| } | |
| def normalize_pest_species(name: str) -> str: | |
| """Normalize a pest species name to the canonical scientific name. | |
| Handles common names ("Brown Planthopper"), scientific names with spaces | |
| ("Nilaparvata lugens"), and canonical names ("nilaparvata_lugens"). | |
| Returns the input lowercased if no alias matches (defensive). | |
| """ | |
| if not name: | |
| return "nilaparvata_lugens" # default | |
| key = name.strip().lower() | |
| if key in PEST_SPECIES_ALIASES: | |
| return PEST_SPECIES_ALIASES[key] | |
| # Try removing underscores and spaces | |
| key_nospace = key.replace("_", " ").replace("-", " ") | |
| if key_nospace in PEST_SPECIES_ALIASES: | |
| return PEST_SPECIES_ALIASES[key_nospace] | |
| # If it's already a canonical name (with underscore), return as-is | |
| if key in PEST_SPECIES: | |
| return key | |
| # Default: return the original lowercased (will likely fail FASTA match, | |
| # but the caller falls back to using all transcripts) | |
| return key | |
| # A/C/G/T (U -> T) one-hot mapping used by SiRNACNN. | |
| NUCLEOTIDE_INDEX: Dict[str, int] = {"A": 0, "C": 1, "G": 2, "T": 3, "U": 3} | |
| # --------------------------------------------------------------------------- # | |
| # FASTA I/O | |
| # --------------------------------------------------------------------------- # | |
| def read_fasta(path: str | Path) -> Dict[str, str]: | |
| """Return ``{sequence_id: sequence}`` from a FASTA file (full load).""" | |
| path = Path(path) | |
| seqs: Dict[str, str] = {} | |
| current_id: str | None = None | |
| chunks: List[str] = [] | |
| with path.open("r", encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if line.startswith(">"): | |
| if current_id is not None: | |
| seqs[current_id] = "".join(chunks) | |
| current_id = line[1:].split()[0] | |
| chunks = [] | |
| else: | |
| chunks.append(line.upper()) | |
| if current_id is not None: | |
| seqs[current_id] = "".join(chunks) | |
| return seqs | |
| def fasta_iter(path: str | Path) -> Iterator[Tuple[str, str]]: | |
| """Generator over ``(id, sequence)`` for large FASTA files.""" | |
| path = Path(path) | |
| with path.open("r", encoding="utf-8") as f: | |
| current_id: str | None = None | |
| chunks: List[str] = [] | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if line.startswith(">"): | |
| if current_id is not None: | |
| yield current_id, "".join(chunks) | |
| current_id = line[1:].split()[0] | |
| chunks = [] | |
| else: | |
| chunks.append(line.upper()) | |
| if current_id is not None: | |
| yield current_id, "".join(chunks) | |
| # --------------------------------------------------------------------------- # | |
| # One-hot encoding | |
| # --------------------------------------------------------------------------- # | |
| def one_hot_encode(seq: str, max_len: int = 21) -> np.ndarray: | |
| """Channel-first one-hot encoding with shape ``(4, max_len)``. | |
| Unknown characters get a uniform ``0.25`` distribution over the 4 bases | |
| so the model still sees a valid probability row instead of all-zeros. | |
| """ | |
| seq = seq.upper().replace("U", "T") | |
| encoded = np.zeros((4, max_len), dtype=np.float32) | |
| for i, ch in enumerate(seq[:max_len]): | |
| if ch in NUCLEOTIDE_INDEX: | |
| encoded[NUCLEOTIDE_INDEX[ch], i] = 1.0 | |
| else: | |
| encoded[:, i] = 0.25 | |
| # pad remaining columns with the uniform distribution too | |
| for i in range(len(seq), max_len): | |
| encoded[:, i] = 0.25 | |
| return encoded | |
| def encode_batch(sequences: List[str], max_len: int = 21) -> np.ndarray: | |
| """Batch of sequences -> numpy array ``(batch, 4, max_len)``.""" | |
| return np.stack([one_hot_encode(s, max_len) for s in sequences], axis=0) | |
| def tokens_to_onehot(tokens: np.ndarray, seq_len: int) -> np.ndarray: | |
| """Convert ``(batch, seq_len)`` integer tokens in ``{0,1,2,3}`` to | |
| ``(batch, 4, seq_len)`` one-hot float32. | |
| """ | |
| batch = tokens.shape[0] | |
| onehot = np.zeros((batch, 4, seq_len), dtype=np.float32) | |
| for b in range(batch): | |
| for i, t in enumerate(tokens[b]): | |
| if 0 <= int(t) < 4: | |
| onehot[b, int(t), i] = 1.0 | |
| else: | |
| onehot[b, :, i] = 0.25 | |
| return onehot | |
| def onehot_to_tokens(onehot: np.ndarray) -> np.ndarray: | |
| """Inverse of :func:`tokens_to_onehot` -- ``(batch, 4, seq_len)`` -> | |
| ``(batch, seq_len)`` integer tokens. | |
| """ | |
| return onehot.argmax(axis=1) | |
| # --------------------------------------------------------------------------- # | |
| # Reverse complement / k-mer generation | |
| # --------------------------------------------------------------------------- # | |
| _COMPLEMENT = {"A": "T", "T": "A", "U": "A", "C": "G", "G": "C"} | |
| def reverse_complement(seq: str) -> str: | |
| return "".join(_COMPLEMENT.get(b, b) for b in reversed(seq)) | |
| def generate_kmers(seq: str, k: int = 21) -> Set[str]: | |
| seq = seq.upper().replace("U", "T") | |
| kmers: Set[str] = set() | |
| if len(seq) < k: | |
| return kmers | |
| for i in range(len(seq) - k + 1): | |
| kmer = seq[i:i + k] | |
| kmers.add(kmer) | |
| kmers.add(reverse_complement(kmer)) | |
| return kmers | |
| # --------------------------------------------------------------------------- # | |
| # dsRNA precursor tiling | |
| # --------------------------------------------------------------------------- # | |
| def tile_sequence( | |
| sequence: str, | |
| window: int = 200, | |
| step: int = 100, | |
| max_candidates: int | None = None, | |
| ) -> List[Tuple[int, int, str]]: | |
| """Tile a transcript into ``window``-nt precursors. | |
| Returns a list of ``(start, end, subseq)`` tuples. ``step`` defaults to | |
| ``window // 2`` so precursors overlap by 50% (covers more splice variants | |
| without blowing up the candidate count). | |
| """ | |
| seq = sequence.upper().replace("U", "T") | |
| if step <= 0: | |
| step = max(1, window // 2) | |
| candidates: List[Tuple[int, int, str]] = [] | |
| for i in range(0, max(0, len(seq) - window + 1), step): | |
| if max_candidates is not None and len(candidates) >= max_candidates: | |
| break | |
| candidates.append((i, i + window, seq[i:i + window])) | |
| return candidates | |
| def dice_precursor(precursor: str, sirna_len: int = 21, step: int = 21) -> List[str]: | |
| """Dice a 200-nt dsRNA precursor into 21-nt siRNAs (Dicer-style). | |
| Default ``step=21`` matches Dicer's processive 21-nt cut cadence. Use a | |
| smaller step (e.g. 7) if you want overlapping windows for dense coverage. | |
| """ | |
| seq = precursor.upper().replace("U", "T") | |
| sirnas: List[str] = [] | |
| for i in range(0, max(0, len(seq) - sirna_len + 1), step): | |
| sirnas.append(seq[i:i + sirna_len]) | |
| return sirnas | |
| # --------------------------------------------------------------------------- # | |
| # K-mer off-target index (copied from src/offtarget/kmer_index.py, no edits | |
| # to behaviour -- the API is what the rest of the pipeline expects) | |
| # --------------------------------------------------------------------------- # | |
| class KmerOffTargetIndex: | |
| """Exact k-mer (default 21-mer, with reverse complements) off-target index. | |
| ``build_from_fasta`` ingests one species; ``per_species_risk`` returns a | |
| dict mapping each indexed species -> fraction of the candidate's k-mers | |
| that hit that species. | |
| """ | |
| def __init__(self, k: int = 21): | |
| self.k = k | |
| self.index: Dict[str, int] = defaultdict(int) | |
| self.species_kmers: Dict[str, Set[str]] = {} | |
| def build_from_fasta( | |
| self, | |
| fasta_path: str | Path, | |
| species_name: str, | |
| header_prefix: str | None = None, | |
| ) -> None: | |
| """Ingest one species from a FASTA file. | |
| If ``header_prefix`` is provided, only sequences whose FASTA header | |
| starts with ``header_prefix`` are ingested (e.g. ``apis_mellifera`` | |
| matches headers like ``>apis_mellifera_fake_001``). This lets a single | |
| multi-species FASTA be indexed per-species without pre-splitting. | |
| If ``header_prefix`` is None (default), all sequences are ingested. | |
| """ | |
| species_set: Set[str] = set() | |
| n_seqs = 0 | |
| for header, seq in fasta_iter(fasta_path): | |
| if header_prefix is not None: | |
| # Header is e.g. "apis_mellifera_fake_001". Match the prefix. | |
| if not header.startswith(header_prefix): | |
| continue | |
| n_seqs += 1 | |
| for kmer in generate_kmers(seq, self.k): | |
| self.index[kmer] += 1 | |
| species_set.add(kmer) | |
| self.species_kmers[species_name] = species_set | |
| print(f"Indexed {len(species_set)} unique {self.k}-mers from {species_name} ({n_seqs} seqs)") | |
| def candidate_offtarget_score(self, candidate_seq: str) -> float: | |
| cand_kmers = generate_kmers(candidate_seq, self.k) | |
| if not cand_kmers: | |
| return 0.0 | |
| hits = sum(1 for kmer in cand_kmers if kmer in self.index) | |
| return hits / len(cand_kmers) | |
| def per_species_risk(self, candidate_seq: str) -> Dict[str, float]: | |
| cand_kmers = generate_kmers(candidate_seq, self.k) | |
| if not cand_kmers: | |
| return {sp: 0.0 for sp in self.species_kmers} | |
| return { | |
| sp: sum(1 for kmer in cand_kmers if kmer in kmers) / len(cand_kmers) | |
| for sp, kmers in self.species_kmers.items() | |
| } | |