Spaces:
Sleeping
Sleeping
| """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 -------------------------------------------------------- # | |
| 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") | |
| 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"] | |