bioseeker 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bioseeker/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """BioSeeker: DNA Sequence Conservation Analyzer."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("bioseeker")
7
+ except PackageNotFoundError:
8
+ # package is not installed
9
+ __version__ = "unknown"
bioseeker/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from bioseeker.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
File without changes
@@ -0,0 +1,67 @@
1
+ """Aggregation of conservation results over many alignment files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Tuple
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from .conservation import ConservationResult
11
+
12
+
13
+ def add_rates(df: pd.DataFrame) -> pd.DataFrame:
14
+ """Return a copy of ``df`` with ``ConservationRate = ConservationCount / ReferenceCount``.
15
+
16
+ The rate is ``NaN`` when ``ReferenceCount`` is 0 (undefined).
17
+ """
18
+ out = df.copy()
19
+ ref = out["ReferenceCount"].to_numpy(dtype=float)
20
+ cons = out["ConservationCount"].to_numpy(dtype=float)
21
+ with np.errstate(divide="ignore", invalid="ignore"):
22
+ out["ConservationRate"] = np.where(ref > 0, cons / np.where(ref > 0, ref, 1), np.nan)
23
+ return out
24
+
25
+
26
+ class ConservationAggregator:
27
+ """Aggregates :class:`ConservationResult` objects from multiple files.
28
+
29
+ All results must have been produced with the same genetic code, so that
30
+ their rows are in the same order.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._codons: Optional[pd.DataFrame] = None
35
+ self._pairs: Optional[pd.DataFrame] = None
36
+ self.n_reference_codons: int = 0
37
+ self.n_alignments: int = 0
38
+
39
+ def add(self, result: ConservationResult) -> None:
40
+ """Add a single result to the running totals."""
41
+ if self._codons is None:
42
+ self._codons = result.codons.copy()
43
+ self._pairs = result.pairs.copy()
44
+ else:
45
+ if not (
46
+ self._codons["Codon"].equals(result.codons["Codon"])
47
+ and self._pairs["CodonPair"].equals(result.pairs["CodonPair"])
48
+ ):
49
+ raise ValueError("Cannot aggregate results computed with different genetic codes")
50
+ for total, new in ((self._codons, result.codons), (self._pairs, result.pairs)):
51
+ for col in ("ReferenceCount", "ConservationCount"):
52
+ total[col] = total[col].to_numpy() + new[col].to_numpy()
53
+ self.n_reference_codons += result.n_reference_codons
54
+ self.n_alignments += 1
55
+
56
+ @property
57
+ def empty(self) -> bool:
58
+ return self._codons is None
59
+
60
+ def get_results(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
61
+ """Return ``(codon_df, pair_df)`` with aggregated counts and conservation rates.
62
+
63
+ Returns two empty DataFrames if nothing was added.
64
+ """
65
+ if self._codons is None:
66
+ return pd.DataFrame(), pd.DataFrame()
67
+ return add_rates(self._codons), add_rates(self._pairs)
@@ -0,0 +1,217 @@
1
+ """Position-specific conservation of codons and codon pairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Sequence, Tuple
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from ..utils.genetic_code import CODON_INDEX, get_genetic_code
12
+
13
+ # Lookup table: ASCII byte -> nucleotide index in TCAG order (or -1 if invalid).
14
+ _BASE_LUT = np.full(256, -1, dtype=np.int16)
15
+ for _i, _b in enumerate("TCAG"):
16
+ _BASE_LUT[ord(_b)] = _i
17
+ _BASE_LUT[ord(_b.lower())] = _i
18
+
19
+
20
+ class ReferenceNotFoundError(ValueError):
21
+ """Raised when the requested reference sequence is not in an alignment."""
22
+
23
+
24
+ @dataclass
25
+ class ConservationResult:
26
+ """Conservation counts for one alignment (or an aggregate of several).
27
+
28
+ Attributes:
29
+ codons: DataFrame with columns ``codon``, ``ReferenceCount``,
30
+ ``ConservationCount`` (one row per sense codon).
31
+ pairs: DataFrame with columns ``codon_pair``, ``ReferenceCount``,
32
+ ``ConservationCount`` (one row per sense codon pair).
33
+ n_reference_codons: Number of valid sense codons in the reference
34
+ (used to derive pseudocounts).
35
+ """
36
+
37
+ codons: pd.DataFrame
38
+ pairs: pd.DataFrame
39
+ n_reference_codons: int
40
+
41
+
42
+ def split_into_codons(sequence: str, orf: int) -> List[str]:
43
+ """Split a sequence into codons starting at offset ``orf``."""
44
+ return [sequence[i : i + 3] for i in range(orf, len(sequence) - 2, 3)]
45
+
46
+
47
+ def split_into_bicodons(sequence: str, orf: int) -> List[str]:
48
+ """Split a sequence into overlapping codon pairs starting at offset ``orf``."""
49
+ return [sequence[i : i + 6] for i in range(orf, len(sequence) - 5, 3)]
50
+
51
+
52
+ def resolve_reference(ids: Sequence[str], reference_id: str | None) -> int:
53
+ """Return the index of ``reference_id`` in ``ids`` (0 if ``reference_id`` is None).
54
+
55
+ Raises:
56
+ ReferenceNotFoundError: If ``reference_id`` is not present in ``ids``.
57
+ """
58
+ if reference_id is None:
59
+ return 0
60
+ try:
61
+ return list(ids).index(reference_id)
62
+ except ValueError:
63
+ raise ReferenceNotFoundError(
64
+ f"Reference '{reference_id}' not found (available: {', '.join(ids)})"
65
+ ) from None
66
+
67
+
68
+ def remove_gap_only_columns(sequences: Sequence[str]) -> List[str]:
69
+ """Drop alignment columns in which every sequence has a gap (``-`` or ``.``).
70
+
71
+ Such columns carry no information about the sequences being compared. They
72
+ appear when species are removed from a larger multiple alignment (e.g. with
73
+ ``--species``), and if kept they would separate codons that are adjacent in
74
+ every remaining sequence. In a codon-aware alignment gaps come in in-frame
75
+ triplets, so removing them preserves the reading frame.
76
+ """
77
+ seqs = list(sequences)
78
+ if not seqs or not seqs[0]:
79
+ return seqs
80
+ arr = np.frombuffer("".join(seqs).encode("ascii", "replace"), dtype=np.uint8).reshape(
81
+ len(seqs), -1
82
+ )
83
+ keep = ~((arr == ord("-")) | (arr == ord("."))).all(axis=0)
84
+ if keep.all():
85
+ return seqs
86
+ return [row.tobytes().decode("ascii") for row in arr[:, keep]]
87
+
88
+
89
+ def encode_codons(sequences: Sequence[str], orf: int) -> np.ndarray:
90
+ """Encode aligned sequences as a matrix of codon indices.
91
+
92
+ Returns:
93
+ ``int16`` array of shape ``(n_sequences, n_codons)`` where each entry is
94
+ the index of the codon in :data:`~bioseeker.utils.genetic_code.ALL_CODONS`
95
+ (0-63), or ``-1`` if the codon contains a gap or a non-ACGT character.
96
+ """
97
+ length = len(sequences[0])
98
+ n_codons = max((length - orf) // 3, 0)
99
+ raw = np.frombuffer(
100
+ "".join(s[orf : orf + 3 * n_codons] for s in sequences).encode("ascii", "replace"),
101
+ dtype=np.uint8,
102
+ ).reshape(len(sequences), n_codons, 3)
103
+ bases = _BASE_LUT[raw]
104
+ idx = bases[..., 0] * 16 + bases[..., 1] * 4 + bases[..., 2]
105
+ idx[(bases < 0).any(axis=2)] = -1
106
+ return idx.astype(np.int16)
107
+
108
+
109
+ def calculate_conservation(
110
+ sequences: Sequence[str],
111
+ orf: int = 0,
112
+ *,
113
+ reference: int = 0,
114
+ threshold: float = 1.0,
115
+ genetic_code: int = 1,
116
+ ) -> ConservationResult:
117
+ """Calculate codon and codon pair conservation for one alignment.
118
+
119
+ Args:
120
+ sequences: Aligned DNA sequences (all of the same length).
121
+ orf: Reading frame offset (0, 1 or 2).
122
+ reference: Index of the reference sequence within ``sequences``.
123
+ threshold: Minimum fraction of sequences (reference included) that must
124
+ share the reference codon for it to count as conserved (0 < t <= 1).
125
+ genetic_code: NCBI translation table ID used to identify stop codons.
126
+
127
+ Returns:
128
+ A :class:`ConservationResult`.
129
+
130
+ Raises:
131
+ ValueError: On invalid arguments or unaligned sequences.
132
+ """
133
+ if orf not in (0, 1, 2):
134
+ raise ValueError("ORF must be 0, 1, or 2")
135
+ if not sequences:
136
+ raise ValueError("No sequences provided")
137
+ if not 0.0 < threshold <= 1.0:
138
+ raise ValueError("threshold must be in the interval (0, 1]")
139
+ if not 0 <= reference < len(sequences):
140
+ raise ValueError(f"reference index {reference} out of range")
141
+ lengths = {len(s) for s in sequences}
142
+ if len(lengths) != 1:
143
+ raise ValueError(
144
+ "Sequences must be aligned (same length) for conservation analysis; "
145
+ f"found lengths {sorted(lengths)}"
146
+ )
147
+
148
+ sequences = remove_gap_only_columns(sequences)
149
+
150
+ code = get_genetic_code(genetic_code)
151
+ sense_mask = np.zeros(64, dtype=bool)
152
+ sense_idx = np.array([CODON_INDEX[c] for c in code.sense_codons], dtype=np.int64)
153
+ sense_mask[sense_idx] = True
154
+
155
+ idx = encode_codons(sequences, orf)
156
+ n_seqs = idx.shape[0]
157
+ ref = idx[reference]
158
+
159
+ # Valid reference positions: ACGT-only and not a stop codon.
160
+ ref_ok = (ref >= 0) & sense_mask[np.clip(ref, 0, 63)]
161
+
162
+ # matches[s, i]: sequence s carries the reference codon at position i.
163
+ matches = idx == ref[np.newaxis, :]
164
+ # Small tolerance so that e.g. threshold=0.9 with 10/10 or 9/10 behaves as expected.
165
+ min_matches = threshold * n_seqs - 1e-9
166
+
167
+ codon_conserved = matches.sum(axis=0) >= min_matches
168
+ pair_matches = matches[:, :-1] & matches[:, 1:]
169
+ pair_conserved = pair_matches.sum(axis=0) >= min_matches
170
+ pair_ok = ref_ok[:-1] & ref_ok[1:]
171
+
172
+ codon_ref = np.bincount(ref[ref_ok], minlength=64)
173
+ codon_cons = np.bincount(ref[ref_ok & codon_conserved], minlength=64)
174
+
175
+ pair_index = ref[:-1].astype(np.int32) * 64 + ref[1:].astype(np.int32)
176
+ pair_ref = np.bincount(pair_index[pair_ok], minlength=4096)
177
+ pair_cons = np.bincount(pair_index[pair_ok & pair_conserved], minlength=4096)
178
+
179
+ pair_sense_idx = (sense_idx[:, None] * 64 + sense_idx[None, :]).ravel()
180
+
181
+ codons = pd.DataFrame(
182
+ {
183
+ "Codon": list(code.sense_codons),
184
+ "ReferenceCount": codon_ref[sense_idx].astype(np.int64),
185
+ "ConservationCount": codon_cons[sense_idx].astype(np.int64),
186
+ }
187
+ )
188
+ pairs = pd.DataFrame(
189
+ {
190
+ "CodonPair": list(code.sense_pairs),
191
+ "ReferenceCount": pair_ref[pair_sense_idx].astype(np.int64),
192
+ "ConservationCount": pair_cons[pair_sense_idx].astype(np.int64),
193
+ }
194
+ )
195
+ return ConservationResult(codons=codons, pairs=pairs, n_reference_codons=int(ref_ok.sum()))
196
+
197
+
198
+ def conservation_for_records(
199
+ records: Sequence[Tuple[str, str]],
200
+ orf: int = 0,
201
+ *,
202
+ reference_id: str | None = None,
203
+ threshold: float = 1.0,
204
+ genetic_code: int = 1,
205
+ ) -> ConservationResult:
206
+ """Convenience wrapper over :func:`calculate_conservation` for ``(id, seq)`` records."""
207
+ if not records:
208
+ raise ValueError("No sequences provided")
209
+ ids = [r[0] for r in records]
210
+ ref = resolve_reference(ids, reference_id)
211
+ return calculate_conservation(
212
+ [r[1] for r in records],
213
+ orf,
214
+ reference=ref,
215
+ threshold=threshold,
216
+ genetic_code=genetic_code,
217
+ )
@@ -0,0 +1,107 @@
1
+ """Relative Synonymous Codon Usage (RSCU) analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import Counter
7
+ from typing import Dict, Iterable, List
8
+
9
+ import pandas as pd
10
+
11
+ from ..io.reader import read_fasta
12
+ from ..utils.genetic_code import get_genetic_code, is_valid_codon
13
+
14
+
15
+ def count_codons_for_sequence(sequence: str) -> Counter[str]:
16
+ """Count non-overlapping trinucleotides in a sequence.
17
+
18
+ Gaps (``-``) are removed before counting. Ambiguous codons (containing
19
+ characters other than ACGT) are ignored.
20
+ """
21
+ seq_clean = sequence.replace("-", "").upper()
22
+ counts: Counter[str] = Counter()
23
+ for i in range(0, len(seq_clean) - len(seq_clean) % 3, 3):
24
+ codon = seq_clean[i : i + 3]
25
+ if is_valid_codon(codon):
26
+ counts[codon] += 1
27
+ return counts
28
+
29
+
30
+ def calculate_rscu_from_counts(
31
+ codon_counts: Counter[str], genetic_code: int = 1
32
+ ) -> Dict[str, float]:
33
+ """Calculate RSCU values from raw codon counts.
34
+
35
+ Returns:
36
+ Mapping from codon to RSCU value. Stop codons are excluded. If an amino
37
+ acid is completely absent, all its synonymous codons get RSCU 0.
38
+ """
39
+ code = get_genetic_code(genetic_code)
40
+ rscu_values: Dict[str, float] = {}
41
+
42
+ for aa, syn_codons in code.synonymous_codons().items():
43
+ total_aa_count = sum(codon_counts[c] for c in syn_codons)
44
+ if total_aa_count == 0:
45
+ for codon in syn_codons:
46
+ rscu_values[codon] = 0.0
47
+ continue
48
+
49
+ expected = total_aa_count / len(syn_codons)
50
+ for codon in syn_codons:
51
+ rscu_values[codon] = codon_counts[codon] / expected
52
+
53
+ return rscu_values
54
+
55
+
56
+ def global_codon_counts(files: Iterable[str | "os.PathLike[str]"]) -> Dict[str, Counter[str]]:
57
+ """Aggregate codon counts per species across multiple alignment files.
58
+
59
+ Args:
60
+ files: Paths to FASTA alignment files.
61
+
62
+ Returns:
63
+ Mapping from species ID to a ``Counter`` of codon occurrences.
64
+ """
65
+ global_counts: Dict[str, Counter[str]] = {}
66
+ for f in files:
67
+ for record_id, seq in read_fasta(f):
68
+ if record_id not in global_counts:
69
+ global_counts[record_id] = Counter()
70
+ global_counts[record_id].update(count_codons_for_sequence(seq))
71
+ return global_counts
72
+
73
+
74
+ def global_rscu(files: Iterable[str | "os.PathLike[str]"], genetic_code: int = 1) -> pd.DataFrame:
75
+ """Calculate the global RSCU per species across multiple alignment files.
76
+
77
+ Returns:
78
+ A DataFrame where the index is the codon (only sense codons) and the
79
+ columns are the species IDs.
80
+ """
81
+ counts = global_codon_counts(files)
82
+ code = get_genetic_code(genetic_code)
83
+ species_list = sorted(counts.keys())
84
+
85
+ data = {sp: calculate_rscu_from_counts(counts[sp], genetic_code) for sp in species_list}
86
+
87
+ df = pd.DataFrame(data, index=code.sense_codons)
88
+ df.index.name = "Codon"
89
+ return df
90
+
91
+
92
+ def identify_rare_codons(
93
+ rscu_df: pd.DataFrame, threshold: float = 1.0, genetic_code: int = 1
94
+ ) -> List[str]:
95
+ """Return sense codons where RSCU < threshold for all species.
96
+
97
+ Amino acids encoded by a single codon (e.g. Met, Trp in the Standard code)
98
+ are excluded since their RSCU is fixed at 1.0. Stop codons are also excluded.
99
+ """
100
+ code = get_genetic_code(genetic_code)
101
+ # Filter out single-codon amino acids
102
+ syn_fams = code.synonymous_codons()
103
+ multi_codons = {c for fam in syn_fams.values() if len(fam) > 1 for c in fam}
104
+
105
+ df = rscu_df[rscu_df.index.isin(multi_codons)]
106
+ rare = df[(df < threshold).all(axis=1)].index.tolist()
107
+ return sorted(rare)
@@ -0,0 +1,223 @@
1
+ """Codon pair statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Iterable, Tuple
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from ..utils.genetic_code import get_genetic_code
12
+ from .assembler import add_rates
13
+
14
+
15
+ @dataclass
16
+ class RegressionResult:
17
+ """Result of a least-squares fit through the origin."""
18
+
19
+ slope: float
20
+ r_squared: float
21
+ sigma: float
22
+ n: int
23
+
24
+ def as_dict(self) -> dict:
25
+ return asdict(self)
26
+
27
+
28
+ def _rate_column(df: pd.DataFrame) -> pd.DataFrame:
29
+ return df if "ConservationRate" in df.columns else add_rates(df)
30
+
31
+
32
+ def calculate_expected_bicodon_rates(
33
+ codon_df: pd.DataFrame, bicodon_df: pd.DataFrame
34
+ ) -> pd.DataFrame:
35
+ """Return a copy of ``bicodon_df`` with ``Codon1``, ``Codon2`` and ``ExpectedRate`` columns.
36
+
37
+ ``ExpectedRate`` is the product of the conservation rates of both codons.
38
+ Neither input is modified.
39
+ """
40
+ codons = _rate_column(codon_df)
41
+ rate_map = pd.Series(
42
+ codons["ConservationRate"].to_numpy(dtype=float), index=codons["Codon"].to_numpy()
43
+ )
44
+
45
+ df = _rate_column(bicodon_df).copy()
46
+ pairs = df["CodonPair"].astype(str)
47
+ df["Codon1"] = pairs.str[:3]
48
+ df["Codon2"] = pairs.str[3:]
49
+ r1 = df["Codon1"].map(rate_map).to_numpy(dtype=float)
50
+ r2 = df["Codon2"].map(rate_map).to_numpy(dtype=float)
51
+ df["ExpectedRate"] = r1 * r2
52
+ return df
53
+
54
+
55
+ def _fit_mask(df: pd.DataFrame, x: str, y: str) -> np.ndarray:
56
+ mask = np.isfinite(df[x].to_numpy(dtype=float)) & np.isfinite(df[y].to_numpy(dtype=float))
57
+ if "ReferenceCount" in df.columns:
58
+ mask &= df["ReferenceCount"].to_numpy() > 0
59
+ return mask
60
+
61
+
62
+ def perform_regression(
63
+ bicodon_df: pd.DataFrame, x: str = "ExpectedRate", y: str = "ConservationRate"
64
+ ) -> RegressionResult:
65
+ """Least-squares fit of ``y = m * x`` (intercept fixed at zero).
66
+
67
+ Rows with ``ReferenceCount == 0`` or non-finite values are ignored.
68
+
69
+ Returns:
70
+ :class:`RegressionResult` with slope, uncentred R², residual standard
71
+ error σ and the number of points used. Values are ``NaN`` when the fit
72
+ is not possible (fewer than 2 points or all ``x`` equal to zero).
73
+ """
74
+ mask = _fit_mask(bicodon_df, x, y)
75
+ xs = bicodon_df[x].to_numpy(dtype=float)[mask]
76
+ ys = bicodon_df[y].to_numpy(dtype=float)[mask]
77
+ n = int(mask.sum())
78
+
79
+ sxx = float(np.dot(xs, xs))
80
+ if n < 2 or sxx == 0.0:
81
+ return RegressionResult(slope=float("nan"), r_squared=float("nan"), sigma=float("nan"), n=n)
82
+
83
+ slope = float(np.dot(xs, ys) / sxx)
84
+ ss_res = float(np.sum((ys - slope * xs) ** 2))
85
+ syy = float(np.dot(ys, ys))
86
+ r_squared = 1.0 - ss_res / syy if syy > 0 else float("nan")
87
+ sigma = float(np.sqrt(ss_res / (n - 1)))
88
+ return RegressionResult(slope=slope, r_squared=r_squared, sigma=sigma, n=n)
89
+
90
+
91
+ def flag_outliers(
92
+ bicodon_df: pd.DataFrame,
93
+ fit: RegressionResult,
94
+ sigma_threshold: float = 3.0,
95
+ x: str = "ExpectedRate",
96
+ y: str = "ConservationRate",
97
+ ) -> pd.DataFrame:
98
+ """Return a copy with ``FittedRate``, ``Residual``, ``SigmaDistance`` and ``Outlier`` columns.
99
+
100
+ ``Outlier`` is ``"high"`` (more conserved than predicted), ``"low"`` (less
101
+ conserved than predicted) or ``""``. Rows excluded from the fit get NaN.
102
+ """
103
+ df = bicodon_df.copy()
104
+ mask = _fit_mask(df, x, y)
105
+ fitted = fit.slope * df[x].to_numpy(dtype=float)
106
+ residual = df[y].to_numpy(dtype=float) - fitted
107
+ fitted[~mask] = np.nan
108
+ residual[~mask] = np.nan
109
+
110
+ if np.isfinite(fit.sigma) and fit.sigma > 0:
111
+ distance = residual / fit.sigma
112
+ else:
113
+ distance = np.full(len(df), np.nan)
114
+
115
+ outlier = np.full(len(df), "", dtype=object)
116
+ with np.errstate(invalid="ignore"):
117
+ outlier[distance > sigma_threshold] = "high"
118
+ outlier[distance < -sigma_threshold] = "low"
119
+
120
+ df["FittedRate"] = fitted
121
+ df["Residual"] = residual
122
+ df["SigmaDistance"] = distance
123
+ df["Outlier"] = outlier
124
+ return df
125
+
126
+
127
+ def normalized_scores(
128
+ bicodon_df: pd.DataFrame, codon_df: pd.DataFrame, pseudocount: float
129
+ ) -> pd.DataFrame:
130
+ """Return a copy with the ``NormalizedScore`` column.
131
+
132
+ ``X = (CPCR + p) / ((CCR1 + p) * (CCR2 + p))``. Pairs never observed in the
133
+ reference (``ReferenceCount == 0``) get ``NaN``.
134
+ """
135
+ if pseudocount < 0:
136
+ raise ValueError("pseudocount must be >= 0")
137
+ df = (
138
+ bicodon_df
139
+ if "Codon1" in bicodon_df.columns
140
+ else calculate_expected_bicodon_rates(codon_df, bicodon_df)
141
+ )
142
+ df = df.copy()
143
+ codons = _rate_column(codon_df)
144
+ rate_map = pd.Series(
145
+ codons["ConservationRate"].to_numpy(dtype=float), index=codons["Codon"].to_numpy()
146
+ )
147
+ r1 = df["Codon1"].map(rate_map).to_numpy(dtype=float)
148
+ r2 = df["Codon2"].map(rate_map).to_numpy(dtype=float)
149
+ cpcr = df["ConservationRate"].to_numpy(dtype=float)
150
+ with np.errstate(divide="ignore", invalid="ignore"):
151
+ score = (cpcr + pseudocount) / ((r1 + pseudocount) * (r2 + pseudocount))
152
+ score[df["ReferenceCount"].to_numpy() == 0] = np.nan
153
+ score[~np.isfinite(score)] = np.nan
154
+ df["NormalizedScore"] = score
155
+ return df
156
+
157
+
158
+ def dipeptide_z_scores(bicodon_df: pd.DataFrame, genetic_code: int = 1) -> pd.DataFrame:
159
+ """Return a copy with ``Dipeptide`` and ``DipeptideZ`` columns.
160
+
161
+ ``DipeptideZ`` is the z-score of ``log(NormalizedScore)`` among all codon
162
+ pairs encoding the same dipeptide. Families with fewer than two valid
163
+ members, or with zero variance, get ``NaN``.
164
+ """
165
+ code = get_genetic_code(genetic_code)
166
+ df = bicodon_df.copy()
167
+ df["Dipeptide"] = [code.dipeptide(p) for p in df["CodonPair"].astype(str)]
168
+ score = df["NormalizedScore"].to_numpy(dtype=float)
169
+ with np.errstate(divide="ignore", invalid="ignore"):
170
+ log_score = np.where(score > 0, np.log(score), np.nan)
171
+ log_s = pd.Series(log_score, index=df.index)
172
+ grouped = log_s.groupby(df["Dipeptide"].to_numpy())
173
+ mean = grouped.transform("mean")
174
+ std = grouped.transform(lambda s: s.std(ddof=1))
175
+ with np.errstate(divide="ignore", invalid="ignore"):
176
+ z = (log_s - mean) / std
177
+ z = z.to_numpy(dtype=float, copy=True)
178
+ z[~np.isfinite(z)] = np.nan
179
+ df["DipeptideZ"] = z
180
+ return df
181
+
182
+
183
+ def analyze_pairs(
184
+ codon_df: pd.DataFrame,
185
+ pair_df: pd.DataFrame,
186
+ n_reference_codons: int,
187
+ *,
188
+ genetic_code: int = 1,
189
+ sigma_threshold: float = 3.0,
190
+ ) -> Tuple[pd.DataFrame, RegressionResult]:
191
+ """Run the full codon-pair analysis.
192
+
193
+ Returns:
194
+ ``(stats_df, fit)`` where ``stats_df`` contains the expected rates,
195
+ regression residuals, outlier flags, normalized scores and dipeptide
196
+ z-scores for every codon pair.
197
+ """
198
+ df = calculate_expected_bicodon_rates(codon_df, pair_df)
199
+ fit = perform_regression(df)
200
+ df = flag_outliers(df, fit, sigma_threshold)
201
+ pseudocount = 1.0 / n_reference_codons if n_reference_codons > 0 else 0.0
202
+ df = normalized_scores(df, codon_df, pseudocount)
203
+ df = dipeptide_z_scores(df, genetic_code)
204
+ return df, fit
205
+
206
+
207
+ def filter_inhibitory_pairs(bicodon_df: pd.DataFrame, rare_codons: Iterable[str]) -> pd.DataFrame:
208
+ """Return the pairs that are high outliers and made of two rare codons."""
209
+ rare = {c.strip().upper() for c in rare_codons}
210
+ pairs = bicodon_df["CodonPair"].astype(str)
211
+ is_rare_pair = pairs.str[:3].isin(rare) & pairs.str[3:].isin(rare)
212
+ is_high = bicodon_df["Outlier"].to_numpy() == "high"
213
+ return bicodon_df[is_rare_pair.to_numpy() & is_high].reset_index(drop=True)
214
+
215
+
216
+ def select_pairs(bicodon_df: pd.DataFrame, pairs: Iterable[str]) -> pd.DataFrame:
217
+ """Return the rows of ``bicodon_df`` for the given codon pairs (e.g. a list of known ICPs).
218
+
219
+ Pairs may be written as ``CGACGA`` or ``CGA-CGA``; RNA ``U`` is converted to ``T``.
220
+ """
221
+ wanted = [p.strip().upper().replace("-", "").replace("U", "T") for p in pairs]
222
+ wanted = [p for p in wanted if p]
223
+ return bicodon_df[bicodon_df["CodonPair"].isin(wanted)].reset_index(drop=True)