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.
@@ -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."""