Biopesticide-AI / bioai /sequence_utils.py
flvcko's picture
Biopesticide-AI: AMD Hackathon Unicorn Track submission
914512c
Raw
History Blame Contribute Delete
13.8 kB
"""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()
}