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 +9 -0
- bioseeker/__main__.py +6 -0
- bioseeker/analysis/__init__.py +0 -0
- bioseeker/analysis/assembler.py +67 -0
- bioseeker/analysis/conservation.py +217 -0
- bioseeker/analysis/rscu_analysis.py +107 -0
- bioseeker/analysis/statistics.py +223 -0
- bioseeker/cli.py +367 -0
- bioseeker/io/__init__.py +0 -0
- bioseeker/io/reader.py +64 -0
- bioseeker/utils/__init__.py +0 -0
- bioseeker/utils/genetic_code.py +89 -0
- bioseeker/visualization/plots.py +67 -0
- bioseeker-2.0.0.dist-info/METADATA +138 -0
- bioseeker-2.0.0.dist-info/RECORD +19 -0
- bioseeker-2.0.0.dist-info/WHEEL +5 -0
- bioseeker-2.0.0.dist-info/entry_points.txt +2 -0
- bioseeker-2.0.0.dist-info/licenses/LICENSE.md +674 -0
- bioseeker-2.0.0.dist-info/top_level.txt +1 -0
bioseeker/__init__.py
ADDED
bioseeker/__main__.py
ADDED
|
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)
|