"""Reynolds 2004 siRNA efficacy rules as feature engineering. Reference: Reynolds et al., Nature Biotechnology 22, 326-330 (2004). "Rational siRNA design for RNA interference". The 8 Reynolds criteria (sense strand, 1-indexed positions from the 5' end): 1. GC content 30-52% 2. No internal repeats (no >4 consecutive identical bases) 3. A or T at position 19 (3' end of sense strand) 4. A at position 3 5. T at position 10 6. A or G at position 13 7. T at position 16 8. Thermodynamic asymmetry: lower binding energy at the 5' antisense end than at the 3' antisense end. We approximate binding energy by GC content of a 7-nt window at each antisense end (since G:C pairs have 3 H-bonds vs A:T/U's 2). The continuous feature is:: thermo_asymmetry = 0.5 * (GC_5prime_antisense - GC_3prime_antisense) Criterion 8 is "met" when thermo_asymmetry < 0 (i.e. the 5' antisense end is less GC-rich = less stable than the 3' antisense end). Pure Python + pandas. No torch dependency. Works on Windows and Linux. """ from __future__ import annotations from typing import Dict, List import pandas as pd class ReynoldsFeaturizer: """Compute Reynolds 2004 siRNA efficacy features for 21-nt sequences. The featurizer is stateless and safe to reuse across calls. """ SIRNA_LEN = 21 # 7-nt windows used to approximate thermodynamic asymmetry. # The 5' end of the antisense strand pairs with the 3' end of the # sense strand (sense indices 14..20), and vice versa. _WINDOW_LEN = 7 _SENSE_3PRIME_START = 14 # inclusive, sense[14:21] = sense 3' end _SENSE_5PRIME_END = 7 # exclusive, sense[0:7] = sense 5' end # ---- public API ----------------------------------------------------- # def featurize(self, sirna_seq: str) -> Dict[str, float]: """Return a dict of Reynolds features for a 21-nt siRNA (sense strand). Accepts RNA (U) or DNA (T) input; U is normalized to T. """ seq = self._normalize(sirna_seq) if len(seq) != self.SIRNA_LEN: raise ValueError( f"siRNA must be {self.SIRNA_LEN} nt long, got {len(seq)} " f"(input: {sirna_seq!r})" ) # Criterion 1: GC content 30-52% gc_count = seq.count("G") + seq.count("C") gc_content = gc_count / self.SIRNA_LEN c1 = int(0.30 <= gc_content <= 0.52) # Criterion 2: no internal repeats (no >4 consecutive identical bases) max_run = self._longest_run(seq) no_repeats = max_run <= 4 c2 = int(no_repeats) # Criterion 3: A or T at position 19 (1-indexed) -> index 18 at_pos19 = seq[18] in ("A", "T") c3 = int(at_pos19) # Criterion 4: A at position 3 -> index 2 a_pos3 = seq[2] == "A" c4 = int(a_pos3) # Criterion 5: T at position 10 -> index 9 t_pos10 = seq[9] == "T" c5 = int(t_pos10) # Criterion 6: A or G at position 13 -> index 12 ag_pos13 = seq[12] in ("A", "G") c6 = int(ag_pos13) # Criterion 7: T at position 16 -> index 15 t_pos16 = seq[15] == "T" c7 = int(t_pos16) # Criterion 8: thermodynamic asymmetry (continuous feature + binary met/not-met) thermo_asymmetry = self._thermo_asymmetry(seq) c8 = int(thermo_asymmetry < 0) reynolds_score = c1 + c2 + c3 + c4 + c5 + c6 + c7 + c8 return { "gc_content": float(gc_content), "no_repeats": c2, "at_pos19": c3, "a_pos3": c4, "t_pos10": c5, "ag_pos13": c6, "t_pos16": c7, "thermo_asymmetry": float(thermo_asymmetry), "reynolds_score": int(reynolds_score), } def featurize_batch(self, seqs: List[str]) -> pd.DataFrame: """Featurize a list of siRNA sequences; returns a DataFrame in input order.""" if not seqs: return pd.DataFrame( columns=[ "gc_content", "no_repeats", "at_pos19", "a_pos3", "t_pos10", "ag_pos13", "t_pos16", "thermo_asymmetry", "reynolds_score", ] ) rows = [self.featurize(s) for s in seqs] return pd.DataFrame(rows) # ---- helpers -------------------------------------------------------- # @staticmethod def _normalize(seq: str) -> str: if not isinstance(seq, str): raise TypeError(f"siRNA sequence must be str, got {type(seq).__name__}") return seq.upper().replace("U", "T") @staticmethod def _longest_run(seq: str) -> int: if not seq: return 0 max_run = 1 cur_run = 1 for i in range(1, len(seq)): if seq[i] == seq[i - 1]: cur_run += 1 if cur_run > max_run: max_run = cur_run else: cur_run = 1 return max_run def _thermo_asymmetry(self, seq: str) -> float: """Approximate thermodynamic asymmetry as 0.5 * (GC_5p_antisense - GC_3p_antisense). The 5' antisense end pairs with the 3' sense end (sense[14:21]), and the 3' antisense end pairs with the 5' sense end (sense[0:7]). GC content is preserved under reverse-complement, so we can read it directly off the sense strand. """ five_prime_window = seq[self._SENSE_3PRIME_START : self.SIRNA_LEN] three_prime_window = seq[0 : self._SENSE_5PRIME_END] gc_5p = ( five_prime_window.count("G") + five_prime_window.count("C") ) / self._WINDOW_LEN gc_3p = ( three_prime_window.count("G") + three_prime_window.count("C") ) / self._WINDOW_LEN return 0.5 * (gc_5p - gc_3p) __all__ = ["ReynoldsFeaturizer"]