CodonAdaptPy 1.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.
- codonadaptpy/__init__.py +67 -0
- codonadaptpy/__main__.py +17 -0
- codonadaptpy/__version__.py +21 -0
- codonadaptpy/aggregation.py +94 -0
- codonadaptpy/analyzer.py +270 -0
- codonadaptpy/cli.py +409 -0
- codonadaptpy/comparative.py +538 -0
- codonadaptpy/exceptions.py +39 -0
- codonadaptpy/genetic_code.py +115 -0
- codonadaptpy/metrics/__init__.py +28 -0
- codonadaptpy/metrics/adaptation.py +133 -0
- codonadaptpy/metrics/composition.py +173 -0
- codonadaptpy/metrics/evolutionary.py +60 -0
- codonadaptpy/metrics/host.py +145 -0
- codonadaptpy/metrics/pairs.py +107 -0
- codonadaptpy/metrics/usage.py +155 -0
- codonadaptpy/models.py +128 -0
- codonadaptpy/optimizer.py +194 -0
- codonadaptpy/parsers.py +220 -0
- codonadaptpy/phylo_visualization.py +334 -0
- codonadaptpy/phylogenetics.py +602 -0
- codonadaptpy/py.typed +1 -0
- codonadaptpy/references.py +169 -0
- codonadaptpy/reporting.py +174 -0
- codonadaptpy/validation.py +169 -0
- codonadaptpy/visualization.py +1073 -0
- codonadaptpy-1.0.0.dist-info/METADATA +346 -0
- codonadaptpy-1.0.0.dist-info/RECORD +32 -0
- codonadaptpy-1.0.0.dist-info/WHEEL +5 -0
- codonadaptpy-1.0.0.dist-info/entry_points.txt +2 -0
- codonadaptpy-1.0.0.dist-info/licenses/LICENSE +14 -0
- codonadaptpy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Comparative and Multivariate Analysis
|
|
3
|
+
|
|
4
|
+
This module performs group summaries, Welch tests, effect-size estimation,
|
|
5
|
+
Benjamini-Hochberg correction, correlation matrices, PCA, linear discriminant
|
|
6
|
+
classification, hierarchical clustering, and pairwise distances. Lightweight
|
|
7
|
+
summaries work with the standard library; multivariate operations require the
|
|
8
|
+
analysis extra.
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
- ComparativeAnalyzer: Analyze collections of scalar sequence metrics.
|
|
12
|
+
|
|
13
|
+
:Created: July 20, 2026
|
|
14
|
+
:Updated: July 20, 2026
|
|
15
|
+
:Author: Naveen Duhan
|
|
16
|
+
:Version: 1.0.0
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import random
|
|
23
|
+
import statistics
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .exceptions import OptionalDependencyError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ComparativeAnalyzer:
|
|
32
|
+
"""Provide group-level statistical and multivariate analysis helpers."""
|
|
33
|
+
|
|
34
|
+
def summarize(self, values: Sequence[float]) -> dict[str, float]:
|
|
35
|
+
"""Return count, mean, median, standard deviation, minimum, and maximum."""
|
|
36
|
+
|
|
37
|
+
clean = [float(value) for value in values if math.isfinite(float(value))]
|
|
38
|
+
if not clean:
|
|
39
|
+
return {key: float("nan") for key in ("count", "mean", "median", "sd", "min", "max")}
|
|
40
|
+
return {
|
|
41
|
+
"count": float(len(clean)),
|
|
42
|
+
"mean": statistics.fmean(clean),
|
|
43
|
+
"median": statistics.median(clean),
|
|
44
|
+
"sd": statistics.stdev(clean) if len(clean) > 1 else 0.0,
|
|
45
|
+
"min": min(clean),
|
|
46
|
+
"max": max(clean),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def group_summaries(
|
|
50
|
+
self, records: Sequence[Mapping[str, Any]], *, group_key: str, metric_keys: Sequence[str]
|
|
51
|
+
) -> dict[str, dict[str, dict[str, float]]]:
|
|
52
|
+
"""Summarize selected numeric metrics for each metadata group."""
|
|
53
|
+
|
|
54
|
+
groups: dict[str, list[Mapping[str, Any]]] = defaultdict(list)
|
|
55
|
+
for record in records:
|
|
56
|
+
groups[str(record[group_key])].append(record)
|
|
57
|
+
return {
|
|
58
|
+
group: {metric: self.summarize([row[metric] for row in rows]) for metric in metric_keys}
|
|
59
|
+
for group, rows in groups.items()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def compare_two_groups(
|
|
63
|
+
self, left: Sequence[float], right: Sequence[float], *, test: str = "welch"
|
|
64
|
+
) -> dict[str, float]:
|
|
65
|
+
"""Run Welch or Mann-Whitney testing and calculate Hedges' g."""
|
|
66
|
+
|
|
67
|
+
if len(left) < 2 or len(right) < 2:
|
|
68
|
+
raise ValueError("At least two observations per group are required.")
|
|
69
|
+
try:
|
|
70
|
+
from scipy import stats
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
raise OptionalDependencyError("Statistical tests require CodonAdaptPy[analysis].") from exc
|
|
73
|
+
if test == "welch":
|
|
74
|
+
test_result = stats.ttest_ind(left, right, equal_var=False, nan_policy="omit")
|
|
75
|
+
elif test == "mannwhitney":
|
|
76
|
+
test_result = stats.mannwhitneyu(left, right, alternative="two-sided")
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError("test must be 'welch' or 'mannwhitney'.")
|
|
79
|
+
n_left, n_right = len(left), len(right)
|
|
80
|
+
pooled = math.sqrt(
|
|
81
|
+
((n_left - 1) * statistics.variance(left) + (n_right - 1) * statistics.variance(right))
|
|
82
|
+
/ (n_left + n_right - 2)
|
|
83
|
+
)
|
|
84
|
+
d = (statistics.fmean(left) - statistics.fmean(right)) / pooled if pooled else 0.0
|
|
85
|
+
correction = 1 - 3 / (4 * (n_left + n_right) - 9)
|
|
86
|
+
return {
|
|
87
|
+
"statistic": float(test_result.statistic),
|
|
88
|
+
"p_value": float(test_result.pvalue),
|
|
89
|
+
"hedges_g": d * correction,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def automatic_test(
|
|
93
|
+
self,
|
|
94
|
+
left: Sequence[float],
|
|
95
|
+
right: Sequence[float],
|
|
96
|
+
*,
|
|
97
|
+
paired: bool = False,
|
|
98
|
+
alpha: float = 0.05,
|
|
99
|
+
estimand: str = "mean",
|
|
100
|
+
confidence: float = 0.95,
|
|
101
|
+
bootstrap_replicates: int = 2000,
|
|
102
|
+
random_seed: int = 1,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Run an estimand-first paired or independent two-sample analysis.
|
|
105
|
+
|
|
106
|
+
``estimand="mean"`` tests a mean difference using a paired t test or
|
|
107
|
+
Welch's unequal-variance t test. ``estimand="distribution"`` tests a
|
|
108
|
+
paired shift with Wilcoxon's signed-rank test or an independent
|
|
109
|
+
stochastic-ordering difference with the Mann--Whitney U test. This
|
|
110
|
+
choice is made from the scientific estimand and sampling design, not
|
|
111
|
+
from a preliminary normality-test decision.
|
|
112
|
+
|
|
113
|
+
Normality diagnostics remain available as model checks. The result
|
|
114
|
+
also includes a percentile-bootstrap confidence interval for the
|
|
115
|
+
corresponding location contrast and an effect size appropriate to the
|
|
116
|
+
selected design. Missing and non-finite observations are removed; in
|
|
117
|
+
paired mode, pairs are removed together to preserve correspondence.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
if not 0 < alpha < 1:
|
|
121
|
+
raise ValueError("alpha must be between zero and one.")
|
|
122
|
+
if estimand not in {"mean", "distribution"}:
|
|
123
|
+
raise ValueError("estimand must be 'mean' or 'distribution'.")
|
|
124
|
+
if not 0 < confidence < 1:
|
|
125
|
+
raise ValueError("confidence must be between zero and one.")
|
|
126
|
+
if bootstrap_replicates < 100:
|
|
127
|
+
raise ValueError("bootstrap_replicates must be at least 100.")
|
|
128
|
+
if paired and len(left) != len(right):
|
|
129
|
+
raise ValueError("Paired samples must contain the same number of observations.")
|
|
130
|
+
try:
|
|
131
|
+
from scipy import stats
|
|
132
|
+
except ImportError as exc:
|
|
133
|
+
raise OptionalDependencyError("Automatic statistical testing requires CodonAdaptPy[analysis].") from exc
|
|
134
|
+
|
|
135
|
+
if paired:
|
|
136
|
+
pairs = [
|
|
137
|
+
(float(first), float(second))
|
|
138
|
+
for first, second in zip(left, right, strict=True)
|
|
139
|
+
if math.isfinite(float(first)) and math.isfinite(float(second))
|
|
140
|
+
]
|
|
141
|
+
if len(pairs) < 3:
|
|
142
|
+
raise ValueError("At least three complete pairs are required for automatic test selection.")
|
|
143
|
+
clean_left = [first for first, _ in pairs]
|
|
144
|
+
clean_right = [second for _, second in pairs]
|
|
145
|
+
differences = [first - second for first, second in pairs]
|
|
146
|
+
normality = {"paired_differences": self._normality(differences, alpha=alpha)}
|
|
147
|
+
if estimand == "mean":
|
|
148
|
+
result = stats.ttest_rel(clean_left, clean_right, nan_policy="omit")
|
|
149
|
+
spread = statistics.stdev(differences)
|
|
150
|
+
effect = statistics.fmean(differences) / spread if spread else 0.0
|
|
151
|
+
test_name = "paired_t"
|
|
152
|
+
effect_name = "cohens_dz"
|
|
153
|
+
contrast_name = "mean_difference"
|
|
154
|
+
contrast = statistics.fmean(differences)
|
|
155
|
+
else:
|
|
156
|
+
result = stats.wilcoxon(clean_left, clean_right, alternative="two-sided")
|
|
157
|
+
nonzero = [value for value in differences if value != 0]
|
|
158
|
+
ranked = stats.rankdata([abs(value) for value in nonzero])
|
|
159
|
+
positive = sum(rank for rank, value in zip(ranked, nonzero, strict=True) if value > 0)
|
|
160
|
+
negative = sum(rank for rank, value in zip(ranked, nonzero, strict=True) if value < 0)
|
|
161
|
+
total = positive + negative
|
|
162
|
+
effect = (positive - negative) / total if total else 0.0
|
|
163
|
+
test_name = "wilcoxon_signed_rank"
|
|
164
|
+
effect_name = "matched_rank_biserial"
|
|
165
|
+
contrast_name = "median_paired_difference"
|
|
166
|
+
contrast = statistics.median(differences)
|
|
167
|
+
else:
|
|
168
|
+
clean_left = [float(value) for value in left if math.isfinite(float(value))]
|
|
169
|
+
clean_right = [float(value) for value in right if math.isfinite(float(value))]
|
|
170
|
+
if len(clean_left) < 3 or len(clean_right) < 3:
|
|
171
|
+
raise ValueError("At least three observations per group are required for automatic test selection.")
|
|
172
|
+
normality = {
|
|
173
|
+
"left": self._normality(clean_left, alpha=alpha),
|
|
174
|
+
"right": self._normality(clean_right, alpha=alpha),
|
|
175
|
+
}
|
|
176
|
+
if estimand == "mean":
|
|
177
|
+
result = stats.ttest_ind(clean_left, clean_right, equal_var=False, nan_policy="omit")
|
|
178
|
+
n_left, n_right = len(clean_left), len(clean_right)
|
|
179
|
+
pooled = math.sqrt(
|
|
180
|
+
((n_left - 1) * statistics.variance(clean_left) + (n_right - 1) * statistics.variance(clean_right))
|
|
181
|
+
/ (n_left + n_right - 2)
|
|
182
|
+
)
|
|
183
|
+
d_value = (statistics.fmean(clean_left) - statistics.fmean(clean_right)) / pooled if pooled else 0.0
|
|
184
|
+
effect = d_value * (1 - 3 / (4 * (n_left + n_right) - 9))
|
|
185
|
+
test_name = "welch_t"
|
|
186
|
+
effect_name = "hedges_g"
|
|
187
|
+
contrast_name = "mean_difference"
|
|
188
|
+
contrast = statistics.fmean(clean_left) - statistics.fmean(clean_right)
|
|
189
|
+
else:
|
|
190
|
+
result = stats.mannwhitneyu(clean_left, clean_right, alternative="two-sided")
|
|
191
|
+
effect = 2 * float(result.statistic) / (len(clean_left) * len(clean_right)) - 1
|
|
192
|
+
test_name = "mann_whitney_u"
|
|
193
|
+
effect_name = "rank_biserial"
|
|
194
|
+
contrast_name = "median_difference"
|
|
195
|
+
contrast = statistics.median(clean_left) - statistics.median(clean_right)
|
|
196
|
+
|
|
197
|
+
confidence_interval = self._bootstrap_location_interval(
|
|
198
|
+
clean_left,
|
|
199
|
+
clean_right,
|
|
200
|
+
paired=paired,
|
|
201
|
+
location="mean" if estimand == "mean" else "median",
|
|
202
|
+
confidence=confidence,
|
|
203
|
+
replicates=bootstrap_replicates,
|
|
204
|
+
random_seed=random_seed,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"test": test_name,
|
|
209
|
+
"paired": paired,
|
|
210
|
+
"alpha": alpha,
|
|
211
|
+
"normality": normality,
|
|
212
|
+
"estimand": estimand,
|
|
213
|
+
"selection_policy": "test selected from the prespecified estimand and paired or independent design",
|
|
214
|
+
"n_left": len(clean_left),
|
|
215
|
+
"n_right": len(clean_right),
|
|
216
|
+
"statistic": float(result.statistic),
|
|
217
|
+
"p_value": float(result.pvalue),
|
|
218
|
+
"effect_size": float(effect),
|
|
219
|
+
"effect_size_name": effect_name,
|
|
220
|
+
"contrast": float(contrast),
|
|
221
|
+
"contrast_name": contrast_name,
|
|
222
|
+
"confidence_level": confidence,
|
|
223
|
+
"confidence_interval": confidence_interval,
|
|
224
|
+
"bootstrap_replicates": bootstrap_replicates,
|
|
225
|
+
"random_seed": random_seed,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _bootstrap_location_interval(
|
|
230
|
+
left: Sequence[float],
|
|
231
|
+
right: Sequence[float],
|
|
232
|
+
*,
|
|
233
|
+
paired: bool,
|
|
234
|
+
location: str,
|
|
235
|
+
confidence: float,
|
|
236
|
+
replicates: int,
|
|
237
|
+
random_seed: int,
|
|
238
|
+
) -> list[float]:
|
|
239
|
+
"""Return a deterministic percentile-bootstrap interval for a contrast."""
|
|
240
|
+
|
|
241
|
+
statistic = statistics.fmean if location == "mean" else statistics.median
|
|
242
|
+
generator = random.Random(random_seed)
|
|
243
|
+
estimates: list[float] = []
|
|
244
|
+
if paired:
|
|
245
|
+
differences = [first - second for first, second in zip(left, right, strict=True)]
|
|
246
|
+
for _ in range(replicates):
|
|
247
|
+
sample = generator.choices(differences, k=len(differences))
|
|
248
|
+
estimates.append(float(statistic(sample)))
|
|
249
|
+
else:
|
|
250
|
+
for _ in range(replicates):
|
|
251
|
+
first = generator.choices(list(left), k=len(left))
|
|
252
|
+
second = generator.choices(list(right), k=len(right))
|
|
253
|
+
estimates.append(float(statistic(first) - statistic(second)))
|
|
254
|
+
estimates.sort()
|
|
255
|
+
tail = (1 - confidence) / 2
|
|
256
|
+
lower = estimates[max(0, math.floor(tail * replicates))]
|
|
257
|
+
upper = estimates[min(replicates - 1, math.ceil((1 - tail) * replicates) - 1)]
|
|
258
|
+
return [lower, upper]
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _normality(values: Sequence[float], *, alpha: float) -> dict[str, float | str | bool]:
|
|
262
|
+
"""Return a named normality test and decision for finite values."""
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
from scipy import stats
|
|
266
|
+
except ImportError as exc:
|
|
267
|
+
raise OptionalDependencyError("Normality testing requires CodonAdaptPy[analysis].") from exc
|
|
268
|
+
if len(values) < 3:
|
|
269
|
+
raise ValueError("Normality testing requires at least three observations.")
|
|
270
|
+
if len(values) <= 5000:
|
|
271
|
+
result = stats.shapiro(values)
|
|
272
|
+
method = "shapiro_wilk"
|
|
273
|
+
else:
|
|
274
|
+
result = stats.normaltest(values)
|
|
275
|
+
method = "dagostino_pearson"
|
|
276
|
+
return {
|
|
277
|
+
"test": method,
|
|
278
|
+
"statistic": float(result.statistic),
|
|
279
|
+
"p_value": float(result.pvalue),
|
|
280
|
+
"normal": bool(result.pvalue >= alpha),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def adjust_bh(p_values: Sequence[float]) -> list[float]:
|
|
285
|
+
"""Apply Benjamini-Hochberg false-discovery-rate correction."""
|
|
286
|
+
|
|
287
|
+
size = len(p_values)
|
|
288
|
+
ordered = sorted(enumerate(p_values), key=lambda item: item[1])
|
|
289
|
+
adjusted = [1.0] * size
|
|
290
|
+
previous = 1.0
|
|
291
|
+
for rank, (index, value) in reversed(list(enumerate(ordered, start=1))):
|
|
292
|
+
previous = min(previous, float(value) * size / rank)
|
|
293
|
+
adjusted[index] = min(1.0, previous)
|
|
294
|
+
return adjusted
|
|
295
|
+
|
|
296
|
+
def multivariate(self, matrix: Sequence[Sequence[float]], *, components: int = 2) -> dict[str, Any]:
|
|
297
|
+
"""Return standardized PCA scores, loadings, variance, and distances."""
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
import numpy as np
|
|
301
|
+
from sklearn.decomposition import PCA
|
|
302
|
+
from sklearn.metrics import pairwise_distances
|
|
303
|
+
from sklearn.preprocessing import StandardScaler
|
|
304
|
+
except ImportError as exc:
|
|
305
|
+
raise OptionalDependencyError("PCA and distances require CodonAdaptPy[analysis].") from exc
|
|
306
|
+
array = np.asarray(matrix, dtype=float)
|
|
307
|
+
scaled = StandardScaler().fit_transform(array)
|
|
308
|
+
model = PCA(n_components=min(components, *scaled.shape)).fit(scaled)
|
|
309
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
310
|
+
corr_matrix = np.nan_to_num(np.corrcoef(array, rowvar=False), nan=0.0).tolist()
|
|
311
|
+
return {
|
|
312
|
+
"scores": model.transform(scaled).tolist(),
|
|
313
|
+
"loadings": model.components_.tolist(),
|
|
314
|
+
"explained_variance_ratio": model.explained_variance_ratio_.tolist(),
|
|
315
|
+
"euclidean_distances": pairwise_distances(scaled).tolist(),
|
|
316
|
+
"correlation_matrix": corr_matrix,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def hierarchical_clustering(
|
|
320
|
+
self,
|
|
321
|
+
matrix: Sequence[Sequence[float]],
|
|
322
|
+
*,
|
|
323
|
+
method: str = "average",
|
|
324
|
+
metric: str = "euclidean",
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""Return SciPy linkage and leaf order for reproducible clustering."""
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
import numpy as np
|
|
330
|
+
from scipy.cluster.hierarchy import leaves_list, linkage
|
|
331
|
+
except ImportError as exc:
|
|
332
|
+
raise OptionalDependencyError("Clustering requires CodonAdaptPy[analysis].") from exc
|
|
333
|
+
linked = linkage(np.asarray(matrix, dtype=float), method=method, metric=metric)
|
|
334
|
+
return {"linkage": linked.tolist(), "leaf_order": leaves_list(linked).tolist()}
|
|
335
|
+
|
|
336
|
+
def linear_discriminant(
|
|
337
|
+
self,
|
|
338
|
+
matrix: Sequence[Sequence[float]],
|
|
339
|
+
labels: Sequence[str],
|
|
340
|
+
*,
|
|
341
|
+
test_size: float = 0.3,
|
|
342
|
+
random_seed: int = 1,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""Train and evaluate stratified linear discriminant classification.
|
|
345
|
+
|
|
346
|
+
The returned projection contains every input observation, while the
|
|
347
|
+
confusion matrix and classification report are calculated only from
|
|
348
|
+
the held-out test subset. The split indices and random seed are
|
|
349
|
+
retained to make reported accuracy exactly reproducible.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
if len(matrix) != len(labels) or len(matrix) < 3:
|
|
353
|
+
raise ValueError("matrix and labels must contain the same three or more observations.")
|
|
354
|
+
if not 0 < test_size < 1:
|
|
355
|
+
raise ValueError("test_size must be between zero and one.")
|
|
356
|
+
try:
|
|
357
|
+
import numpy as np
|
|
358
|
+
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
|
359
|
+
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
|
360
|
+
from sklearn.model_selection import train_test_split
|
|
361
|
+
except ImportError as exc:
|
|
362
|
+
raise OptionalDependencyError("LDA requires CodonAdaptPy[analysis].") from exc
|
|
363
|
+
values = np.asarray(matrix, dtype=float)
|
|
364
|
+
categories = np.asarray([str(label) for label in labels])
|
|
365
|
+
classes, counts = np.unique(categories, return_counts=True)
|
|
366
|
+
if len(classes) < 2 or np.any(counts < 2):
|
|
367
|
+
raise ValueError("LDA requires at least two classes with two observations each.")
|
|
368
|
+
indices = np.arange(len(categories))
|
|
369
|
+
train_indices, test_indices = train_test_split(
|
|
370
|
+
indices,
|
|
371
|
+
test_size=test_size,
|
|
372
|
+
random_state=random_seed,
|
|
373
|
+
stratify=categories,
|
|
374
|
+
)
|
|
375
|
+
model = LinearDiscriminantAnalysis().fit(values[train_indices], categories[train_indices])
|
|
376
|
+
predicted = model.predict(values[test_indices])
|
|
377
|
+
dimensions = min(2, len(classes) - 1)
|
|
378
|
+
projection = model.transform(values)[:, :dimensions]
|
|
379
|
+
return {
|
|
380
|
+
"classes": classes.tolist(),
|
|
381
|
+
"accuracy": float(accuracy_score(categories[test_indices], predicted)),
|
|
382
|
+
"confusion_matrix": confusion_matrix(categories[test_indices], predicted, labels=classes).tolist(),
|
|
383
|
+
"classification_report": classification_report(
|
|
384
|
+
categories[test_indices],
|
|
385
|
+
predicted,
|
|
386
|
+
labels=classes,
|
|
387
|
+
output_dict=True,
|
|
388
|
+
zero_division=0,
|
|
389
|
+
),
|
|
390
|
+
"projection": projection.tolist(),
|
|
391
|
+
"predictions": predicted.tolist(),
|
|
392
|
+
"train_indices": train_indices.tolist(),
|
|
393
|
+
"test_indices": test_indices.tolist(),
|
|
394
|
+
"test_labels": categories[test_indices].tolist(),
|
|
395
|
+
"test_size": test_size,
|
|
396
|
+
"random_seed": random_seed,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
def repeated_grouped_discriminant(
|
|
400
|
+
self,
|
|
401
|
+
matrix: Sequence[Sequence[float]],
|
|
402
|
+
labels: Sequence[str],
|
|
403
|
+
groups: Sequence[str | int],
|
|
404
|
+
*,
|
|
405
|
+
folds: int = 5,
|
|
406
|
+
repeats: int = 20,
|
|
407
|
+
random_seed: int = 1,
|
|
408
|
+
shrinkage: bool = True,
|
|
409
|
+
) -> dict[str, Any]:
|
|
410
|
+
"""Evaluate LDA with repeated stratified, group-exclusive folds.
|
|
411
|
+
|
|
412
|
+
All observations sharing a group are kept in the same fold, preventing
|
|
413
|
+
exact duplicates, isolates, or sequence clusters from appearing in
|
|
414
|
+
both training and test data. Metrics are reported per fold and as
|
|
415
|
+
mean, standard deviation, and percentile intervals across all held-out
|
|
416
|
+
predictions. Shrinkage LDA is used by default for high-dimensional
|
|
417
|
+
codon-feature matrices.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
if len(matrix) != len(labels) or len(labels) != len(groups) or len(matrix) < 4:
|
|
421
|
+
raise ValueError("matrix, labels, and groups must contain the same four or more observations.")
|
|
422
|
+
if folds < 2 or repeats < 1:
|
|
423
|
+
raise ValueError("folds must be at least two and repeats at least one.")
|
|
424
|
+
try:
|
|
425
|
+
import numpy as np
|
|
426
|
+
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
|
427
|
+
from sklearn.metrics import accuracy_score, balanced_accuracy_score, confusion_matrix, f1_score
|
|
428
|
+
from sklearn.model_selection import StratifiedGroupKFold
|
|
429
|
+
except ImportError as exc:
|
|
430
|
+
raise OptionalDependencyError("Repeated grouped LDA requires CodonAdaptPy[analysis].") from exc
|
|
431
|
+
|
|
432
|
+
values = np.asarray(matrix, dtype=float)
|
|
433
|
+
categories = np.asarray([str(label) for label in labels])
|
|
434
|
+
group_values = np.asarray([str(group) for group in groups])
|
|
435
|
+
classes = np.unique(categories)
|
|
436
|
+
if len(classes) < 2:
|
|
437
|
+
raise ValueError("Grouped LDA requires at least two classes.")
|
|
438
|
+
groups_per_class = [len(np.unique(group_values[categories == category])) for category in classes]
|
|
439
|
+
if min(groups_per_class) < folds:
|
|
440
|
+
raise ValueError("Each class must contain at least as many distinct groups as folds.")
|
|
441
|
+
|
|
442
|
+
fold_results: list[dict[str, Any]] = []
|
|
443
|
+
confusion = np.zeros((len(classes), len(classes)), dtype=int)
|
|
444
|
+
for repeat in range(repeats):
|
|
445
|
+
seed = random_seed + repeat
|
|
446
|
+
splitter = StratifiedGroupKFold(n_splits=folds, shuffle=True, random_state=seed)
|
|
447
|
+
for fold, (train_indices, test_indices) in enumerate(
|
|
448
|
+
splitter.split(values, categories, groups=group_values), start=1
|
|
449
|
+
):
|
|
450
|
+
train_groups = set(group_values[train_indices])
|
|
451
|
+
test_groups = set(group_values[test_indices])
|
|
452
|
+
if train_groups & test_groups:
|
|
453
|
+
raise RuntimeError("A group crossed the training/test boundary.")
|
|
454
|
+
parameters = {"solver": "lsqr", "shrinkage": "auto"} if shrinkage else {"solver": "svd"}
|
|
455
|
+
model = LinearDiscriminantAnalysis(**parameters).fit(values[train_indices], categories[train_indices])
|
|
456
|
+
predicted = model.predict(values[test_indices])
|
|
457
|
+
observed = categories[test_indices]
|
|
458
|
+
fold_confusion = confusion_matrix(observed, predicted, labels=classes)
|
|
459
|
+
confusion += fold_confusion
|
|
460
|
+
fold_results.append(
|
|
461
|
+
{
|
|
462
|
+
"repeat": repeat + 1,
|
|
463
|
+
"fold": fold,
|
|
464
|
+
"random_seed": seed,
|
|
465
|
+
"accuracy": float(accuracy_score(observed, predicted)),
|
|
466
|
+
"balanced_accuracy": float(balanced_accuracy_score(observed, predicted)),
|
|
467
|
+
"macro_f1": float(f1_score(observed, predicted, average="macro", zero_division=0)),
|
|
468
|
+
"train_indices": train_indices.tolist(),
|
|
469
|
+
"test_indices": test_indices.tolist(),
|
|
470
|
+
"train_groups": sorted(train_groups),
|
|
471
|
+
"test_groups": sorted(test_groups),
|
|
472
|
+
"observed": observed.tolist(),
|
|
473
|
+
"predicted": predicted.tolist(),
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
summary: dict[str, dict[str, float | list[float]]] = {}
|
|
478
|
+
for metric in ("accuracy", "balanced_accuracy", "macro_f1"):
|
|
479
|
+
metric_values = np.asarray([row[metric] for row in fold_results], dtype=float)
|
|
480
|
+
summary[metric] = {
|
|
481
|
+
"mean": float(metric_values.mean()),
|
|
482
|
+
"sd": float(metric_values.std(ddof=1)) if len(metric_values) > 1 else 0.0,
|
|
483
|
+
"interval_95": np.percentile(metric_values, [2.5, 97.5]).tolist(),
|
|
484
|
+
"min": float(metric_values.min()),
|
|
485
|
+
"max": float(metric_values.max()),
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
"classes": classes.tolist(),
|
|
489
|
+
"folds": folds,
|
|
490
|
+
"repeats": repeats,
|
|
491
|
+
"random_seed": random_seed,
|
|
492
|
+
"shrinkage": shrinkage,
|
|
493
|
+
"group_exclusive": True,
|
|
494
|
+
"summary": summary,
|
|
495
|
+
"aggregate_confusion_matrix": confusion.tolist(),
|
|
496
|
+
"fold_results": fold_results,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
def correspondence_analysis(self, matrix: Sequence[Sequence[float]], *, components: int = 2) -> dict[str, Any]:
|
|
500
|
+
"""Perform correspondence analysis on a non-negative count/RSCU matrix."""
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
import numpy as np
|
|
504
|
+
except ImportError as exc:
|
|
505
|
+
raise OptionalDependencyError("Correspondence analysis requires CodonAdaptPy[analysis].") from exc
|
|
506
|
+
values = np.asarray(matrix, dtype=float)
|
|
507
|
+
if values.ndim != 2 or values.size == 0 or np.any(values < 0) or values.sum() <= 0:
|
|
508
|
+
raise ValueError("Correspondence analysis requires a non-empty non-negative matrix.")
|
|
509
|
+
probabilities = values / values.sum()
|
|
510
|
+
row_masses = probabilities.sum(axis=1)
|
|
511
|
+
column_masses = probabilities.sum(axis=0)
|
|
512
|
+
expected = np.outer(row_masses, column_masses)
|
|
513
|
+
residuals = np.divide(
|
|
514
|
+
probabilities - expected,
|
|
515
|
+
np.sqrt(expected),
|
|
516
|
+
out=np.zeros_like(probabilities),
|
|
517
|
+
where=expected > 0,
|
|
518
|
+
)
|
|
519
|
+
left, singular, right = np.linalg.svd(residuals, full_matrices=False)
|
|
520
|
+
count = min(components, len(singular))
|
|
521
|
+
row_coordinates = np.divide(
|
|
522
|
+
left[:, :count] * singular[:count],
|
|
523
|
+
np.sqrt(row_masses)[:, None],
|
|
524
|
+
out=np.zeros((values.shape[0], count)),
|
|
525
|
+
where=row_masses[:, None] > 0,
|
|
526
|
+
)
|
|
527
|
+
column_coordinates = np.divide(
|
|
528
|
+
right[:count, :].T * singular[:count],
|
|
529
|
+
np.sqrt(column_masses)[:, None],
|
|
530
|
+
out=np.zeros((values.shape[1], count)),
|
|
531
|
+
where=column_masses[:, None] > 0,
|
|
532
|
+
)
|
|
533
|
+
inertia = singular**2
|
|
534
|
+
return {
|
|
535
|
+
"row_coordinates": row_coordinates.tolist(),
|
|
536
|
+
"column_coordinates": column_coordinates.tolist(),
|
|
537
|
+
"explained_inertia": (inertia[:count] / inertia.sum()).tolist() if inertia.sum() else [0.0] * count,
|
|
538
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Domain Exceptions
|
|
3
|
+
|
|
4
|
+
This module defines focused exception types used by the package. Callers may
|
|
5
|
+
catch the common :class:`CodonAdaptPyError` base class or respond precisely to
|
|
6
|
+
input, reference-data, analysis, and optional-dependency failures.
|
|
7
|
+
|
|
8
|
+
Exceptions:
|
|
9
|
+
- CodonAdaptPyError: Base class for package-specific failures.
|
|
10
|
+
- SequenceValidationError: Raised when strict sequence validation fails.
|
|
11
|
+
- ReferenceDataError: Raised for incomplete or malformed reference data.
|
|
12
|
+
- AnalysisError: Raised when a metric cannot be calculated meaningfully.
|
|
13
|
+
- OptionalDependencyError: Raised when a requested extra is unavailable.
|
|
14
|
+
|
|
15
|
+
:Created: July 20, 2026
|
|
16
|
+
:Updated: July 20, 2026
|
|
17
|
+
:Author: Naveen Duhan
|
|
18
|
+
:Version: 1.0.0
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodonAdaptPyError(Exception):
|
|
23
|
+
"""Base exception for all anticipated CodonAdaptPy errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SequenceValidationError(CodonAdaptPyError, ValueError):
|
|
27
|
+
"""Indicate that a coding sequence violates strict validation settings."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReferenceDataError(CodonAdaptPyError, ValueError):
|
|
31
|
+
"""Indicate that codon reference data are absent, invalid, or incomplete."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AnalysisError(CodonAdaptPyError, ValueError):
|
|
35
|
+
"""Indicate that an analysis cannot produce a scientifically valid result."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OptionalDependencyError(CodonAdaptPyError, ImportError):
|
|
39
|
+
"""Indicate that an explicitly requested optional feature needs an extra."""
|