Biopesticide-AI / scripts /data /reynolds_features.py
flvcko's picture
Biopesticide-AI: AMD Hackathon Unicorn Track submission
914512c
Raw
History Blame Contribute Delete
6 kB
"""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"]