"""Build the final training CSV by combining: * siRNA sequences + knockdown labels (from synthetic or real input) * Reynolds 2004 efficacy features (from `reynolds_features.py`) * Off-target risk scores against the 6 non-target species (using the `KmerOffTargetIndex` from `_legacy_imports.py`) Output columns: sirna_seq, target_gene, knockdown_pct, pest_label, gc_content, no_repeats, at_pos19, a_pos3, t_pos10, ag_pos13, t_pos16, thermo_asymmetry, reynolds_score, offtarget_apis_mellifera, offtarget_bos_taurus, offtarget_bos_indicus, offtarget_gallus_gallus, offtarget_danio_rerio, offtarget_homo_sapiens `pest_label` is the binarized knockdown_pct (1 if KD >= 0.7 else 0). This is the SCIENTIFICALLY CORRECT label (the original merged biopesticide AI code wrongly labeled any 100-nt tile from a pest gene as pest_label=1, which has no basis in efficacy). Usage (run from the project root): # Synthetic path (works without any external data): python -m scripts.data.build_training_csv --source synthetic # Real path (requires real siRNA CSV + NCBI rna.fna files on disk): python -m scripts.data.build_training_csv --source real python -m scripts.data.build_training_csv --source real \ --input /path/to/your/sirna_real.csv Dependencies: pandas, numpy (transitively via reynolds_features), tqdm. """ from __future__ import annotations import argparse import logging import sys from collections import defaultdict from pathlib import Path from typing import Dict, Iterable, List, Tuple import pandas as pd try: from tqdm import tqdm except ImportError: # pragma: no cover def tqdm(iterable, **_kwargs): return iterable # Legacy helpers (copied from upload/merged_biopesticide_ai.py to avoid # depending on the soon-to-be-refactored `src/` package layout). from scripts.data._legacy_imports import ( KmerOffTargetIndex, SAFETY_SPECIES, TARGET_SPECIES, fasta_iter, generate_kmers, ) from scripts.data.reynolds_features import ReynoldsFeaturizer # --------------------------------------------------------------------------- # # Logging + paths # --------------------------------------------------------------------------- # logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) log = logging.getLogger("build_training_csv") PROJECT_ROOT = Path(__file__).resolve().parents[2] # Canonical 6-species safety panel (same order as the output CSV columns). SAFETY_PANEL: Tuple[str, ...] = tuple(SAFETY_SPECIES) # Default I/O paths. SYNTHETIC_INPUT = PROJECT_ROOT / "data" / "synthetic" / "sirna_training.csv" SYNTHETIC_SAFETY_FASTA = PROJECT_ROOT / "data" / "synthetic" / "safety_transcripts.fasta" SYNTHETIC_OUTPUT = PROJECT_ROOT / "data" / "processed" / "training_data.csv" REAL_INPUT_DEFAULT = PROJECT_ROOT / "data" / "external" / "sirna_real.csv" REAL_OUTPUT = PROJECT_ROOT / "data" / "processed" / "training_data_real.csv" # Output column order (spec-defined, dynamic per safety species count). _BASE_COLUMNS: Tuple[str, ...] = ( "sirna_seq", "target_gene", "knockdown_pct", "pest_label", "gc_content", "no_repeats", "at_pos19", "a_pos3", "t_pos10", "ag_pos13", "t_pos16", "thermo_asymmetry", "reynolds_score", ) OUTPUT_COLUMNS: Tuple[str, ...] = _BASE_COLUMNS + tuple( f"offtarget_{sp}" for sp in SAFETY_SPECIES ) # K-mer size used by the off-target index. Must match the legacy default. KMER_K = 21 # Binaraization threshold (task spec). PEST_LABEL_THRESHOLD = 0.7 # NCBI species -> assembly id, for the real-data safety panel. Matches # `download_sources.py`. The pest species (nilaparvata_lugens) is excluded # from the safety panel. NCBI_SPECIES_ASSEMBLIES: Dict[str, str] = { "apis_mellifera": "GCF_003254395.2", "bos_taurus": "GCF_002263795.1", "bos_indicus": "GCA_014661045.1", "gallus_gallus": "GCF_016699485.2", "danio_rerio": "GCF_000002035.4", "homo_sapiens": "GCF_000001405.40", } # --------------------------------------------------------------------------- # # Indexing helpers # --------------------------------------------------------------------------- # def index_synthetic_safety_panel( idx: KmerOffTargetIndex, fasta_path: Path ) -> Dict[str, int]: """Build the off-target index from the synthetic safety FASTA. The synthetic FASTA groups 5 species (skip human) into a single file with headers like `>apis_mellifera_fake_001`. We group records by their species prefix (everything before `_fake_`) and feed each group into the index. Returns a dict {species_name: number_of_records_indexed}. """ by_species: Dict[str, List[str]] = defaultdict(list) for header, seq in fasta_iter(fasta_path): # header is e.g. 'apis_mellifera_fake_001' -> species 'apis_mellifera' if "_fake_" in header: species = header.split("_fake_", 1)[0] else: # Fallback: take the first two underscore-separated tokens. parts = header.split("_") species = "_".join(parts[:2]) if len(parts) >= 2 else header by_species[species].append(seq) counts: Dict[str, int] = {} for species, seqs in by_species.items(): species_set = set() for seq in seqs: for kmer in generate_kmers(seq, idx.k): idx.index[kmer] += 1 species_set.add(kmer) idx.species_kmers[species] = species_set counts[species] = len(seqs) log.info( " Indexed %-20s %4d transcripts -> %d unique %d-mers", species, len(seqs), len(species_set), idx.k, ) return counts def index_real_safety_panel(idx: KmerOffTargetIndex) -> Dict[str, Path]: """Build the off-target index from on-disk NCBI rna.fna files. Missing files are logged and skipped; the corresponding species' off-target column will be 0.0 in the output. """ found: Dict[str, Path] = {} for species, assembly in NCBI_SPECIES_ASSEMBLIES.items(): rna_fna = ( PROJECT_ROOT / "data" / species / "ncbi_dataset" / "data" / assembly / "rna.fna" ) if not rna_fna.is_file(): log.warning( " Missing NCBI file for %s: %s -> offtarget_%s will be 0.0", species, rna_fna, species, ) continue log.info(" Indexing %s from %s", species, rna_fna) idx.build_from_fasta(rna_fna, species) found[species] = rna_fna return found # --------------------------------------------------------------------------- # # Input loading + validation # --------------------------------------------------------------------------- # def load_input_siRNAs(path: Path) -> pd.DataFrame: """Load the input siRNA CSV. Required columns: sirna_seq, target_gene, knockdown_pct. Extra columns (e.g. `source`) are kept but not used. """ if not path.is_file(): raise FileNotFoundError( f"Input siRNA CSV not found: {path}\n" f" For --source synthetic, run `python -m scripts.data.generate_synthetic` first.\n" f" For --source real, place a CSV at {REAL_INPUT_DEFAULT} or pass --input ." ) df = pd.read_csv(path) required = {"sirna_seq", "target_gene", "knockdown_pct"} missing = required - set(df.columns) if missing: raise ValueError( f"Input CSV {path} is missing required columns: {sorted(missing)}. " f"Found columns: {list(df.columns)}" ) # Coerce knockdown_pct to float; validate range. df["knockdown_pct"] = pd.to_numeric(df["knockdown_pct"], errors="coerce") if df["knockdown_pct"].isna().any(): raise ValueError(f"Input CSV {path} has non-numeric knockdown_pct values.") if ((df["knockdown_pct"] < 0.0) | (df["knockdown_pct"] > 1.0)).any(): log.warning(" Some knockdown_pct values are outside [0, 1]; clipping.") df["knockdown_pct"] = df["knockdown_pct"].clip(0.0, 1.0) # Strip whitespace from sequence + uppercase + T-normalize (DNA). df["sirna_seq"] = ( df["sirna_seq"].astype(str).str.strip().str.upper().str.replace("U", "T") ) # Validate length. bad_lens = df[df["sirna_seq"].str.len() != 21] if not bad_lens.empty: raise ValueError( f"Input CSV has {len(bad_lens)} siRNAs that are not 21 nt long. " f"First few: {bad_lens['sirna_seq'].head(5).tolist()}" ) # Drop exact duplicate sequences (keep first). n_before = len(df) df = df.drop_duplicates(subset=["sirna_seq"], keep="first").reset_index(drop=True) if len(df) < n_before: log.info(" Dropped %d duplicate siRNA sequences.", n_before - len(df)) return df # --------------------------------------------------------------------------- # # Main build # --------------------------------------------------------------------------- # def build( source: str, input_csv: Path, output_csv: Path, ) -> None: log.info("=" * 70) log.info("Build configuration:") log.info(" source: %s", source) log.info(" input CSV: %s", input_csv) log.info(" output CSV: %s", output_csv) log.info(" K-mer size: %d", KMER_K) log.info(" KD threshold: %.2f (>= -> pest_label=1)", PEST_LABEL_THRESHOLD) log.info("=" * 70) # ---- 1. Load input siRNAs ------------------------------------------ # log.info("STEP 1/4: Loading input siRNAs.") df_in = load_input_siRNAs(input_csv) log.info(" Loaded %d unique siRNAs.", len(df_in)) # ---- 2. Build off-target index ------------------------------------- # log.info("STEP 2/4: Building off-target index over safety panel.") idx = KmerOffTargetIndex(k=KMER_K) if source == "synthetic": if not SYNTHETIC_SAFETY_FASTA.is_file(): raise FileNotFoundError( f"Synthetic safety FASTA not found: {SYNTHETIC_SAFETY_FASTA}\n" f" Run `python -m scripts.data.generate_synthetic` first." ) log.info(" Indexing synthetic safety panel: %s", SYNTHETIC_SAFETY_FASTA) counts = index_synthetic_safety_panel(idx, SYNTHETIC_SAFETY_FASTA) missing = [sp for sp in SAFETY_PANEL if sp not in idx.species_kmers] if missing: log.info( " Synthetic path intentionally skips: %s " "(these columns will be 0.0 in the output).", ", ".join(missing), ) else: # real log.info(" Indexing real NCBI safety panel.") found = index_real_safety_panel(idx) missing = [sp for sp in SAFETY_PANEL if sp not in idx.species_kmers] if missing: log.warning( " Real path is missing NCBI data for: %s " "(these columns will be 0.0 in the output).", ", ".join(missing), ) if not found: log.warning( " No NCBI safety FASTAs found on disk. Proceeding with all " "off-target columns set to 0.0. Run " "`python -m scripts.data.download_sources` to verify paths, " "or place real rna.fna files under data//ncbi_dataset/." ) # ---- 3. Featurize + off-target scoring ----------------------------- # log.info("STEP 3/4: Featurizing siRNAs (Reynolds + off-target).") featurizer = ReynoldsFeaturizer() rows: List[dict] = [] for _, row in tqdm(df_in.iterrows(), total=len(df_in), desc="siRNAs"): seq = row["sirna_seq"] kd = float(row["knockdown_pct"]) feats = featurizer.featurize(seq) risks = idx.per_species_risk(seq) out_row = { "sirna_seq": seq, "target_gene": row["target_gene"], "knockdown_pct": kd, "pest_label": int(kd >= PEST_LABEL_THRESHOLD), } out_row.update(feats) for sp in SAFETY_PANEL: out_row[f"offtarget_{sp}"] = float(risks.get(sp, 0.0)) rows.append(out_row) df_out = pd.DataFrame(rows, columns=list(OUTPUT_COLUMNS)) # ---- 4. Write output ----------------------------------------------- # log.info("STEP 4/4: Writing output CSV.") output_csv.parent.mkdir(parents=True, exist_ok=True) df_out.to_csv(output_csv, index=False) log.info(" Wrote %d rows to %s", len(df_out), output_csv) # ---- Summary ------------------------------------------------------- # log.info("-" * 70) log.info("Build complete.") log.info(" Total siRNAs: %d", len(df_out)) log.info(" pest_label=1: %d (%.1f%%)", int(df_out["pest_label"].sum()), 100.0 * df_out["pest_label"].mean()) log.info(" pest_label=0: %d (%.1f%%)", int((1 - df_out["pest_label"]).sum()), 100.0 * (1 - df_out["pest_label"]).mean()) log.info(" Mean Reynolds score: %.2f / 8", df_out["reynolds_score"].mean()) log.info(" Off-target column non-zero counts:") for sp in SAFETY_PANEL: col = f"offtarget_{sp}" nz = int((df_out[col] > 0).sum()) log.info(" %-28s %4d / %d non-zero", col, nz, len(df_out)) # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( prog="python -m scripts.data.build_training_csv", description=( "Build the final training CSV by combining siRNA efficacy " "labels, Reynolds 2004 features, and off-target risk against " "the 6-species safety panel." ), ) p.add_argument( "--source", choices=("synthetic", "real"), required=True, help="Data source: 'synthetic' uses data/synthetic/, 'real' uses " "data/external/sirna_real.csv + on-disk NCBI rna.fna files.", ) p.add_argument( "--input", type=Path, default=None, help="Override the input siRNA CSV path. Defaults to " "data/synthetic/sirna_training.csv (synthetic) or " "data/external/sirna_real.csv (real).", ) p.add_argument( "--output", type=Path, default=None, help="Override the output CSV path. Defaults to " "data/processed/training_data.csv (synthetic) or " "data/processed/training_data_real.csv (real).", ) return p.parse_args(argv) def main() -> int: args = parse_args() if args.input is not None: input_csv = args.input else: input_csv = SYNTHETIC_INPUT if args.source == "synthetic" else REAL_INPUT_DEFAULT if args.output is not None: output_csv = args.output else: output_csv = SYNTHETIC_OUTPUT if args.source == "synthetic" else REAL_OUTPUT try: build(source=args.source, input_csv=input_csv, output_csv=output_csv) except FileNotFoundError as exc: log.error("%s", exc) return 2 except (ValueError, RuntimeError) as exc: log.error("Build failed: %s", exc) return 1 return 0 if __name__ == "__main__": sys.exit(main())