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,1073 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Publication and Interactive Plots
|
|
3
|
+
|
|
4
|
+
This module creates interactive Plotly figures and publication-ready static
|
|
5
|
+
Matplotlib figures for RSCU heatmaps, ENC-GC3 diagnostics, neutrality and PR2
|
|
6
|
+
plots, group distributions, ordinations, dinucleotide profiles, correlation
|
|
7
|
+
matrices, PCA, and sliding-window CAI profiles.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
- PlotManager: Plot construction and format-aware figure export.
|
|
11
|
+
- PublicationPlotManager: Colorblind-safe static scientific figures.
|
|
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
|
+
from collections import defaultdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .exceptions import OptionalDependencyError
|
|
27
|
+
from .models import AnalysisResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlotManager:
|
|
31
|
+
"""Build consistent Plotly figures from structured analysis results."""
|
|
32
|
+
|
|
33
|
+
def _plotly(self) -> tuple[Any, Any]:
|
|
34
|
+
"""Import Plotly lazily so core analysis has no plotting dependency."""
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import plotly.express as px
|
|
38
|
+
import plotly.graph_objects as go
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise OptionalDependencyError("Plots require CodonAdaptPy[plot].") from exc
|
|
41
|
+
return px, go
|
|
42
|
+
|
|
43
|
+
def rscu_heatmap(self, results: list[AnalysisResult]) -> Any:
|
|
44
|
+
"""Create a sequence-by-codon RSCU heatmap."""
|
|
45
|
+
|
|
46
|
+
_, go = self._plotly()
|
|
47
|
+
from .genetic_code import GeneticCode
|
|
48
|
+
|
|
49
|
+
code = GeneticCode.from_ncbi(1)
|
|
50
|
+
codons = sorted(
|
|
51
|
+
codon
|
|
52
|
+
for amino_acid, family in code.synonymous_codons.items()
|
|
53
|
+
if amino_acid != "*" and len(family) > 1
|
|
54
|
+
for codon in family
|
|
55
|
+
)
|
|
56
|
+
return go.Figure(
|
|
57
|
+
data=go.Heatmap(
|
|
58
|
+
z=[[result.rscu.get(codon, 0.0) for codon in codons] for result in results],
|
|
59
|
+
x=codons,
|
|
60
|
+
y=[result.identifier for result in results],
|
|
61
|
+
colorscale="Viridis",
|
|
62
|
+
)
|
|
63
|
+
).update_layout(title="Relative Synonymous Codon Usage", xaxis_title="Codon", yaxis_title="Sequence")
|
|
64
|
+
|
|
65
|
+
def enc_gc3(self, results: list[AnalysisResult]) -> Any:
|
|
66
|
+
"""Create observed ENC versus GC3 with the mutation-only expectation."""
|
|
67
|
+
|
|
68
|
+
_, go = self._plotly()
|
|
69
|
+
gc3_grid = [index / 100 for index in range(101)]
|
|
70
|
+
from .metrics.evolutionary import EvolutionaryMetrics
|
|
71
|
+
|
|
72
|
+
figure = go.Figure()
|
|
73
|
+
figure.add_scatter(
|
|
74
|
+
x=[result.metrics["gc3"] for result in results],
|
|
75
|
+
y=[result.metrics["enc"] for result in results],
|
|
76
|
+
mode="markers+text",
|
|
77
|
+
text=[result.identifier for result in results],
|
|
78
|
+
name="Observed",
|
|
79
|
+
)
|
|
80
|
+
figure.add_scatter(
|
|
81
|
+
x=gc3_grid, y=[EvolutionaryMetrics.expected_enc(value) for value in gc3_grid], mode="lines", name="Expected"
|
|
82
|
+
)
|
|
83
|
+
return figure.update_layout(title="ENC-GC3 Plot", xaxis_title="GC3", yaxis_title="Effective Number of Codons")
|
|
84
|
+
|
|
85
|
+
def sliding_cai(self, result: AnalysisResult) -> Any:
|
|
86
|
+
"""Create a sliding-window CAI line profile for one sequence."""
|
|
87
|
+
|
|
88
|
+
_, go = self._plotly()
|
|
89
|
+
values = result.sliding_windows.get("cai", [])
|
|
90
|
+
return go.Figure(
|
|
91
|
+
data=go.Scatter(
|
|
92
|
+
x=[item["start_codon"] for item in values], y=[item["cai"] for item in values], mode="lines"
|
|
93
|
+
)
|
|
94
|
+
).update_layout(
|
|
95
|
+
title=f"Sliding-window CAI: {result.identifier}", xaxis_title="Starting codon", yaxis_title="CAI"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def neutrality(self, results: list[AnalysisResult]) -> Any:
|
|
99
|
+
"""Create a GC12-versus-GC3 neutrality scatter with linear trend."""
|
|
100
|
+
|
|
101
|
+
px, _ = self._plotly()
|
|
102
|
+
rows = [
|
|
103
|
+
{
|
|
104
|
+
"identifier": result.identifier,
|
|
105
|
+
"GC3": result.metrics["gc3"],
|
|
106
|
+
"GC12": (result.metrics["gc1"] + result.metrics["gc2"]) / 2,
|
|
107
|
+
}
|
|
108
|
+
for result in results
|
|
109
|
+
]
|
|
110
|
+
return px.scatter(rows, x="GC3", y="GC12", text="identifier", trendline="ols", title="Neutrality Plot")
|
|
111
|
+
|
|
112
|
+
def parity_rule_2(self, results: list[AnalysisResult]) -> Any:
|
|
113
|
+
"""Create a PR2 plot centered on the equal-bias expectation."""
|
|
114
|
+
|
|
115
|
+
px, _ = self._plotly()
|
|
116
|
+
rows = [
|
|
117
|
+
{
|
|
118
|
+
"identifier": result.identifier,
|
|
119
|
+
"A3/(A3+T3)": result.metrics["at_bias"],
|
|
120
|
+
"G3/(G3+C3)": result.metrics["gc_bias"],
|
|
121
|
+
}
|
|
122
|
+
for result in results
|
|
123
|
+
]
|
|
124
|
+
figure = px.scatter(
|
|
125
|
+
rows,
|
|
126
|
+
x="A3/(A3+T3)",
|
|
127
|
+
y="G3/(G3+C3)",
|
|
128
|
+
text="identifier",
|
|
129
|
+
title="Parity Rule 2 Plot",
|
|
130
|
+
)
|
|
131
|
+
figure.add_hline(y=0.5, line_dash="dash")
|
|
132
|
+
figure.add_vline(x=0.5, line_dash="dash")
|
|
133
|
+
return figure
|
|
134
|
+
|
|
135
|
+
def group_distribution(
|
|
136
|
+
self,
|
|
137
|
+
rows: list[dict[str, Any]],
|
|
138
|
+
*,
|
|
139
|
+
metric: str,
|
|
140
|
+
group: str,
|
|
141
|
+
kind: str = "box",
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""Create box, violin, or bar comparisons from flat metric records."""
|
|
144
|
+
|
|
145
|
+
px, _ = self._plotly()
|
|
146
|
+
if kind == "box":
|
|
147
|
+
return px.box(rows, x=group, y=metric, points="all", title=f"{metric} by {group}")
|
|
148
|
+
if kind == "violin":
|
|
149
|
+
return px.violin(rows, x=group, y=metric, box=True, points="all", title=f"{metric} by {group}")
|
|
150
|
+
if kind == "bar":
|
|
151
|
+
return px.bar(rows, x=group, y=metric, title=f"{metric} by {group}")
|
|
152
|
+
raise ValueError("kind must be 'box', 'violin', or 'bar'.")
|
|
153
|
+
|
|
154
|
+
def matrix_heatmap(
|
|
155
|
+
self,
|
|
156
|
+
matrix: list[list[float]],
|
|
157
|
+
*,
|
|
158
|
+
row_labels: list[str],
|
|
159
|
+
column_labels: list[str],
|
|
160
|
+
title: str = "Matrix Heatmap",
|
|
161
|
+
) -> Any:
|
|
162
|
+
"""Create correlation, distance, or other labeled matrix heatmaps."""
|
|
163
|
+
|
|
164
|
+
_, go = self._plotly()
|
|
165
|
+
return go.Figure(
|
|
166
|
+
data=go.Heatmap(z=matrix, x=column_labels, y=row_labels, colorscale="RdBu", zmid=0)
|
|
167
|
+
).update_layout(title=title)
|
|
168
|
+
|
|
169
|
+
def pca(self, scores: list[list[float]], labels: list[str]) -> Any:
|
|
170
|
+
"""Create an interactive PCA score plot from comparative output."""
|
|
171
|
+
|
|
172
|
+
px, _ = self._plotly()
|
|
173
|
+
rows = [
|
|
174
|
+
{"identifier": label, "PC1": score[0], "PC2": score[1] if len(score) > 1 else 0.0}
|
|
175
|
+
for label, score in zip(labels, scores, strict=True)
|
|
176
|
+
]
|
|
177
|
+
return px.scatter(rows, x="PC1", y="PC2", text="identifier", title="Principal Component Analysis")
|
|
178
|
+
|
|
179
|
+
def save(self, figure: Any, path: str | Path) -> Path:
|
|
180
|
+
"""Save interactive HTML or static SVG/PNG/PDF based on suffix."""
|
|
181
|
+
|
|
182
|
+
destination = Path(path)
|
|
183
|
+
if destination.suffix.lower() == ".html":
|
|
184
|
+
figure.write_html(destination, include_plotlyjs="cdn")
|
|
185
|
+
else:
|
|
186
|
+
try:
|
|
187
|
+
figure.write_image(destination)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise OptionalDependencyError("Static plots require a working Kaleido installation.") from exc
|
|
190
|
+
return destination
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PublicationPlotManager:
|
|
194
|
+
"""Create journal-ready static figures from CodonAdaptPy outputs.
|
|
195
|
+
|
|
196
|
+
The manager uses the Okabe-Ito colorblind-safe palette, redundant marker
|
|
197
|
+
shapes, outward-offset axes, visible individual observations, and vector-
|
|
198
|
+
compatible Matplotlib artists. Matplotlib is imported lazily so sequence
|
|
199
|
+
analysis remains dependency-free.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
COLORS = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9")
|
|
203
|
+
MARKERS = ("o", "s", "^", "D", "P", "X")
|
|
204
|
+
METRIC_LABELS = {
|
|
205
|
+
"gc": "GC",
|
|
206
|
+
"gc1": "GC1",
|
|
207
|
+
"gc2": "GC2",
|
|
208
|
+
"gc3": "GC3",
|
|
209
|
+
"gc3s": "GC3s",
|
|
210
|
+
"at1": "AT1",
|
|
211
|
+
"at2": "AT2",
|
|
212
|
+
"at3": "AT3",
|
|
213
|
+
"enc": "ENC",
|
|
214
|
+
"cpg_representation": "CpG O/E",
|
|
215
|
+
"upa_representation": "UpA O/E",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def _matplotlib(self) -> tuple[Any, Any]:
|
|
219
|
+
"""Import Matplotlib lazily and apply consistent publication styling."""
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
import matplotlib.pyplot as plt
|
|
223
|
+
import numpy as np
|
|
224
|
+
except ImportError as exc:
|
|
225
|
+
raise OptionalDependencyError(
|
|
226
|
+
"Publication plots require CodonAdaptPy[plot] with Matplotlib."
|
|
227
|
+
) from exc
|
|
228
|
+
plt.rcParams.update(
|
|
229
|
+
{
|
|
230
|
+
"font.family": "sans-serif",
|
|
231
|
+
"font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
|
|
232
|
+
"font.size": 8,
|
|
233
|
+
"axes.labelsize": 9,
|
|
234
|
+
"axes.titlesize": 10,
|
|
235
|
+
"xtick.labelsize": 7,
|
|
236
|
+
"ytick.labelsize": 7,
|
|
237
|
+
"legend.fontsize": 7,
|
|
238
|
+
"pdf.fonttype": 42,
|
|
239
|
+
"svg.fonttype": "none",
|
|
240
|
+
"figure.max_open_warning": 50,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
return plt, np
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _clean_axes(axis: Any) -> None:
|
|
247
|
+
"""Remove chart junk and separate the visible left and bottom spines."""
|
|
248
|
+
|
|
249
|
+
axis.spines["top"].set_visible(False)
|
|
250
|
+
axis.spines["right"].set_visible(False)
|
|
251
|
+
axis.spines["left"].set_position(("outward", 5))
|
|
252
|
+
axis.spines["bottom"].set_position(("outward", 5))
|
|
253
|
+
axis.tick_params(direction="out", length=3, width=0.7)
|
|
254
|
+
|
|
255
|
+
def ordination(
|
|
256
|
+
self,
|
|
257
|
+
scores: list[list[float]],
|
|
258
|
+
labels: list[str],
|
|
259
|
+
groups: list[str],
|
|
260
|
+
*,
|
|
261
|
+
x_label: str = "Axis 1",
|
|
262
|
+
y_label: str = "Axis 2",
|
|
263
|
+
title: str = "Ordination",
|
|
264
|
+
) -> Any:
|
|
265
|
+
"""Plot grouped two-dimensional scores with centroid standard errors.
|
|
266
|
+
|
|
267
|
+
Individual samples are shown with color and shape encoding. Larger
|
|
268
|
+
black-edged points show group centroids, and error bars represent the
|
|
269
|
+
standard error independently along both axes.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
if not (len(scores) == len(labels) == len(groups)) or not scores:
|
|
273
|
+
raise ValueError("scores, labels, and groups must have equal non-zero lengths.")
|
|
274
|
+
if any(len(score) < 2 for score in scores):
|
|
275
|
+
raise ValueError("Each ordination score must contain at least two axes.")
|
|
276
|
+
plt, np = self._matplotlib()
|
|
277
|
+
figure, axis = plt.subplots(figsize=(7.2, 5.0), constrained_layout=True)
|
|
278
|
+
ordered_groups = sorted({str(group) for group in groups})
|
|
279
|
+
for index, group in enumerate(ordered_groups):
|
|
280
|
+
positions = [position for position, value in enumerate(groups) if str(value) == group]
|
|
281
|
+
values = np.asarray([scores[position][:2] for position in positions], dtype=float)
|
|
282
|
+
color = self.COLORS[index % len(self.COLORS)]
|
|
283
|
+
marker = self.MARKERS[index % len(self.MARKERS)]
|
|
284
|
+
axis.scatter(
|
|
285
|
+
values[:, 0],
|
|
286
|
+
values[:, 1],
|
|
287
|
+
s=28,
|
|
288
|
+
color=color,
|
|
289
|
+
marker=marker,
|
|
290
|
+
alpha=0.78,
|
|
291
|
+
edgecolor="white",
|
|
292
|
+
linewidth=0.45,
|
|
293
|
+
label=f"{group} (n={len(values)})",
|
|
294
|
+
)
|
|
295
|
+
centroid = values.mean(axis=0)
|
|
296
|
+
standard_error = values.std(axis=0, ddof=1) / math.sqrt(len(values)) if len(values) > 1 else [0, 0]
|
|
297
|
+
axis.errorbar(
|
|
298
|
+
centroid[0],
|
|
299
|
+
centroid[1],
|
|
300
|
+
xerr=standard_error[0],
|
|
301
|
+
yerr=standard_error[1],
|
|
302
|
+
fmt=marker,
|
|
303
|
+
markersize=7,
|
|
304
|
+
markerfacecolor=color,
|
|
305
|
+
markeredgecolor="black",
|
|
306
|
+
markeredgewidth=0.8,
|
|
307
|
+
ecolor=color,
|
|
308
|
+
elinewidth=1.0,
|
|
309
|
+
capsize=2.5,
|
|
310
|
+
zorder=4,
|
|
311
|
+
)
|
|
312
|
+
axis.axhline(0, color="#BDBDBD", linewidth=0.6, zorder=0)
|
|
313
|
+
axis.axvline(0, color="#BDBDBD", linewidth=0.6, zorder=0)
|
|
314
|
+
axis.set(xlabel=x_label, ylabel=y_label, title=title)
|
|
315
|
+
axis.legend(frameon=False, title="Genotype")
|
|
316
|
+
self._clean_axes(axis)
|
|
317
|
+
return figure
|
|
318
|
+
|
|
319
|
+
def metric_panels(
|
|
320
|
+
self,
|
|
321
|
+
rows: list[dict[str, Any]],
|
|
322
|
+
*,
|
|
323
|
+
metrics: list[str],
|
|
324
|
+
group: str,
|
|
325
|
+
title: str = "Metric distributions",
|
|
326
|
+
columns: int = 2,
|
|
327
|
+
) -> Any:
|
|
328
|
+
"""Create box-and-point panels for numeric metrics grouped by metadata.
|
|
329
|
+
|
|
330
|
+
Boxes show the median and interquartile range; whiskers follow the
|
|
331
|
+
conventional 1.5-IQR rule. Every observation is overlaid using
|
|
332
|
+
deterministic horizontal jitter, and each category label includes its
|
|
333
|
+
sample size.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
if not rows or not metrics:
|
|
337
|
+
raise ValueError("rows and metrics must be non-empty.")
|
|
338
|
+
plt, np = self._matplotlib()
|
|
339
|
+
group_names = sorted({str(row[group]) for row in rows})
|
|
340
|
+
panel_columns = min(columns, len(metrics))
|
|
341
|
+
panel_rows = math.ceil(len(metrics) / panel_columns)
|
|
342
|
+
figure, axes = plt.subplots(
|
|
343
|
+
panel_rows,
|
|
344
|
+
panel_columns,
|
|
345
|
+
figsize=(7.2, 2.9 * panel_rows),
|
|
346
|
+
constrained_layout=True,
|
|
347
|
+
squeeze=False,
|
|
348
|
+
)
|
|
349
|
+
random = np.random.default_rng(1)
|
|
350
|
+
for panel_index, metric in enumerate(metrics):
|
|
351
|
+
axis = axes.flat[panel_index]
|
|
352
|
+
values = [
|
|
353
|
+
[float(row[metric]) for row in rows if str(row[group]) == name and row.get(metric) is not None]
|
|
354
|
+
for name in group_names
|
|
355
|
+
]
|
|
356
|
+
boxes = axis.boxplot(values, patch_artist=True, widths=0.55, showfliers=False)
|
|
357
|
+
for index, patch in enumerate(boxes["boxes"]):
|
|
358
|
+
patch.set_facecolor(self.COLORS[index % len(self.COLORS)])
|
|
359
|
+
patch.set_alpha(0.34)
|
|
360
|
+
patch.set_edgecolor(self.COLORS[index % len(self.COLORS)])
|
|
361
|
+
for index, observations in enumerate(values, start=1):
|
|
362
|
+
jitter = random.uniform(-0.12, 0.12, len(observations))
|
|
363
|
+
axis.scatter(
|
|
364
|
+
np.full(len(observations), index) + jitter,
|
|
365
|
+
observations,
|
|
366
|
+
s=15,
|
|
367
|
+
color=self.COLORS[(index - 1) % len(self.COLORS)],
|
|
368
|
+
alpha=0.68,
|
|
369
|
+
edgecolor="white",
|
|
370
|
+
linewidth=0.3,
|
|
371
|
+
)
|
|
372
|
+
axis.set_xticks(
|
|
373
|
+
range(1, len(group_names) + 1),
|
|
374
|
+
[f"{name}\n(n={len(values[index])})" for index, name in enumerate(group_names)],
|
|
375
|
+
)
|
|
376
|
+
axis.set_xlabel(group)
|
|
377
|
+
display_metric = self.METRIC_LABELS.get(metric, metric.replace("_", " "))
|
|
378
|
+
axis.set_ylabel(display_metric)
|
|
379
|
+
axis.set_title(display_metric)
|
|
380
|
+
self._clean_axes(axis)
|
|
381
|
+
for axis in axes.flat[len(metrics) :]:
|
|
382
|
+
axis.set_visible(False)
|
|
383
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
384
|
+
return figure
|
|
385
|
+
|
|
386
|
+
def dinucleotide_heatmap(
|
|
387
|
+
self,
|
|
388
|
+
results: list[AnalysisResult],
|
|
389
|
+
*,
|
|
390
|
+
group_key: str,
|
|
391
|
+
title: str = "Mean dinucleotide observed/expected ratios",
|
|
392
|
+
) -> Any:
|
|
393
|
+
"""Plot mean O/E ratios for all 16 dinucleotides by metadata group."""
|
|
394
|
+
|
|
395
|
+
if not results:
|
|
396
|
+
raise ValueError("results must be non-empty.")
|
|
397
|
+
plt, np = self._matplotlib()
|
|
398
|
+
grouped: dict[str, list[AnalysisResult]] = defaultdict(list)
|
|
399
|
+
for result in results:
|
|
400
|
+
grouped[str(result.metadata.get(group_key, "Unknown"))].append(result)
|
|
401
|
+
group_names = sorted(grouped)
|
|
402
|
+
dinucleotides = [left + right for left in "ACGT" for right in "ACGT"]
|
|
403
|
+
matrix = np.asarray(
|
|
404
|
+
[
|
|
405
|
+
[
|
|
406
|
+
np.mean(
|
|
407
|
+
[
|
|
408
|
+
result.dinucleotide_odds[pair]
|
|
409
|
+
for result in grouped[name]
|
|
410
|
+
if result.dinucleotide_odds[pair] is not None
|
|
411
|
+
]
|
|
412
|
+
)
|
|
413
|
+
for pair in dinucleotides
|
|
414
|
+
]
|
|
415
|
+
for name in group_names
|
|
416
|
+
],
|
|
417
|
+
dtype=float,
|
|
418
|
+
)
|
|
419
|
+
figure, axis = plt.subplots(figsize=(7.2, max(2.6, 0.58 * len(group_names))), constrained_layout=True)
|
|
420
|
+
image = axis.imshow(matrix, cmap="BrBG", vmin=0.65, vmax=1.35, aspect="auto")
|
|
421
|
+
axis.set_xticks(range(len(dinucleotides)), dinucleotides, rotation=45, ha="right")
|
|
422
|
+
axis.set_yticks(range(len(group_names)), [f"{name} (n={len(grouped[name])})" for name in group_names])
|
|
423
|
+
axis.set_xlabel("Dinucleotide")
|
|
424
|
+
axis.set_ylabel("Genotype")
|
|
425
|
+
axis.set_title(title)
|
|
426
|
+
for row_index in range(matrix.shape[0]):
|
|
427
|
+
for column_index in range(matrix.shape[1]):
|
|
428
|
+
axis.text(
|
|
429
|
+
column_index,
|
|
430
|
+
row_index,
|
|
431
|
+
f"{matrix[row_index, column_index]:.2f}",
|
|
432
|
+
ha="center",
|
|
433
|
+
va="center",
|
|
434
|
+
fontsize=5.5,
|
|
435
|
+
)
|
|
436
|
+
colorbar = figure.colorbar(image, ax=axis, fraction=0.025, pad=0.02)
|
|
437
|
+
colorbar.set_label("Observed/expected ratio")
|
|
438
|
+
return figure
|
|
439
|
+
|
|
440
|
+
def rscu_heatmap(
|
|
441
|
+
self,
|
|
442
|
+
results: list[AnalysisResult],
|
|
443
|
+
*,
|
|
444
|
+
group_key: str,
|
|
445
|
+
title: str = "Mean relative synonymous codon usage",
|
|
446
|
+
) -> Any:
|
|
447
|
+
"""Plot group-mean RSCU values for all available sense codons."""
|
|
448
|
+
|
|
449
|
+
if not results:
|
|
450
|
+
raise ValueError("results must be non-empty.")
|
|
451
|
+
plt, np = self._matplotlib()
|
|
452
|
+
grouped: dict[str, list[AnalysisResult]] = defaultdict(list)
|
|
453
|
+
for result in results:
|
|
454
|
+
grouped[str(result.metadata.get(group_key, "Unknown"))].append(result)
|
|
455
|
+
group_names = sorted(grouped)
|
|
456
|
+
codons = sorted({codon for result in results for codon in result.rscu})
|
|
457
|
+
matrix = np.asarray(
|
|
458
|
+
[
|
|
459
|
+
[np.mean([result.rscu.get(codon, 0.0) for result in grouped[name]]) for codon in codons]
|
|
460
|
+
for name in group_names
|
|
461
|
+
],
|
|
462
|
+
dtype=float,
|
|
463
|
+
)
|
|
464
|
+
figure, axis = plt.subplots(figsize=(10.0, max(2.7, 0.58 * len(group_names))), constrained_layout=True)
|
|
465
|
+
image = axis.imshow(matrix, cmap="viridis", vmin=0.0, vmax=max(2.0, float(matrix.max())), aspect="auto")
|
|
466
|
+
axis.set_xticks(range(len(codons)), codons, rotation=90)
|
|
467
|
+
axis.set_yticks(range(len(group_names)), [f"{name} (n={len(grouped[name])})" for name in group_names])
|
|
468
|
+
axis.set_xlabel("Codon")
|
|
469
|
+
axis.set_ylabel("Genotype")
|
|
470
|
+
axis.set_title(title)
|
|
471
|
+
colorbar = figure.colorbar(image, ax=axis, fraction=0.018, pad=0.02)
|
|
472
|
+
colorbar.set_label("Mean RSCU")
|
|
473
|
+
return figure
|
|
474
|
+
|
|
475
|
+
def evolutionary_diagnostics(
|
|
476
|
+
self,
|
|
477
|
+
results: list[AnalysisResult],
|
|
478
|
+
*,
|
|
479
|
+
group_key: str,
|
|
480
|
+
title: str = "Evolutionary codon-usage diagnostics",
|
|
481
|
+
) -> Any:
|
|
482
|
+
"""Create ENC-GC3, neutrality, and PR2 panels by metadata group."""
|
|
483
|
+
|
|
484
|
+
if not results:
|
|
485
|
+
raise ValueError("results must be non-empty.")
|
|
486
|
+
plt, np = self._matplotlib()
|
|
487
|
+
from .metrics.evolutionary import EvolutionaryMetrics
|
|
488
|
+
|
|
489
|
+
figure, axes = plt.subplots(1, 3, figsize=(10.8, 3.5), constrained_layout=True)
|
|
490
|
+
group_names = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
|
|
491
|
+
for index, group in enumerate(group_names):
|
|
492
|
+
selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
|
|
493
|
+
color = self.COLORS[index % len(self.COLORS)]
|
|
494
|
+
marker = self.MARKERS[index % len(self.MARKERS)]
|
|
495
|
+
common = {
|
|
496
|
+
"s": 25,
|
|
497
|
+
"color": color,
|
|
498
|
+
"marker": marker,
|
|
499
|
+
"alpha": 0.75,
|
|
500
|
+
"edgecolor": "white",
|
|
501
|
+
"linewidth": 0.4,
|
|
502
|
+
"label": f"{group} (n={len(selected)})",
|
|
503
|
+
}
|
|
504
|
+
axes[0].scatter(
|
|
505
|
+
[result.metrics["gc3s"] for result in selected],
|
|
506
|
+
[result.metrics["enc"] for result in selected],
|
|
507
|
+
**common,
|
|
508
|
+
)
|
|
509
|
+
axes[1].scatter(
|
|
510
|
+
[result.metrics["gc3"] for result in selected],
|
|
511
|
+
[(result.metrics["gc1"] + result.metrics["gc2"]) / 2 for result in selected],
|
|
512
|
+
**common,
|
|
513
|
+
)
|
|
514
|
+
selected_gc3 = np.asarray([result.metrics["gc3"] for result in selected], dtype=float)
|
|
515
|
+
selected_gc12 = np.asarray(
|
|
516
|
+
[(result.metrics["gc1"] + result.metrics["gc2"]) / 2 for result in selected],
|
|
517
|
+
dtype=float,
|
|
518
|
+
)
|
|
519
|
+
if len(selected) >= 2 and np.ptp(selected_gc3) > 0:
|
|
520
|
+
slope, intercept = np.polyfit(selected_gc3, selected_gc12, 1)
|
|
521
|
+
trend = np.linspace(float(selected_gc3.min()), float(selected_gc3.max()), 100)
|
|
522
|
+
axes[1].plot(trend, intercept + slope * trend, color=color, linewidth=1.0, linestyle="--")
|
|
523
|
+
axes[2].scatter(
|
|
524
|
+
[result.metrics["at_bias"] for result in selected],
|
|
525
|
+
[result.metrics["gc_bias"] for result in selected],
|
|
526
|
+
**common,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
grid = np.linspace(0.01, 0.99, 200)
|
|
530
|
+
axes[0].plot(
|
|
531
|
+
grid,
|
|
532
|
+
[EvolutionaryMetrics.expected_enc(float(value)) for value in grid],
|
|
533
|
+
color="black",
|
|
534
|
+
linewidth=1.0,
|
|
535
|
+
linestyle="--",
|
|
536
|
+
label="Mutation-only expectation",
|
|
537
|
+
)
|
|
538
|
+
axes[2].axhline(0.5, color="black", linewidth=0.8, linestyle="--")
|
|
539
|
+
axes[2].axvline(0.5, color="black", linewidth=0.8, linestyle="--")
|
|
540
|
+
axes[0].set(xlabel="GC3s", ylabel="Effective number of codons", title="ENC-GC3s")
|
|
541
|
+
axes[1].set(xlabel="GC3", ylabel="GC12", title="Neutrality")
|
|
542
|
+
axes[2].set(xlabel="A3/(A3+T3)", ylabel="G3/(G3+C3)", title="PR2")
|
|
543
|
+
for index, axis in enumerate(axes):
|
|
544
|
+
axis.text(-0.16, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
|
|
545
|
+
self._clean_axes(axis)
|
|
546
|
+
handles, labels = axes[0].get_legend_handles_labels()
|
|
547
|
+
figure.legend(
|
|
548
|
+
handles,
|
|
549
|
+
labels,
|
|
550
|
+
loc="lower center",
|
|
551
|
+
bbox_to_anchor=(0.5, -0.03),
|
|
552
|
+
ncol=min(4, len(labels)),
|
|
553
|
+
frameon=False,
|
|
554
|
+
)
|
|
555
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
556
|
+
return figure
|
|
557
|
+
|
|
558
|
+
def rscu_profiles(
|
|
559
|
+
self,
|
|
560
|
+
results: list[AnalysisResult],
|
|
561
|
+
*,
|
|
562
|
+
group_key: str,
|
|
563
|
+
title: str = "Relative synonymous codon usage profiles",
|
|
564
|
+
) -> Any:
|
|
565
|
+
"""Plot group-mean 59-codon RSCU profiles with standard errors.
|
|
566
|
+
|
|
567
|
+
Codons are arranged by amino-acid family and colored by their third
|
|
568
|
+
nucleotide. Horizontal reference lines identify the conventional
|
|
569
|
+
underrepresentation (0.6) and preference (1.6) thresholds.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
if not results:
|
|
573
|
+
raise ValueError("results must be non-empty.")
|
|
574
|
+
plt, np = self._matplotlib()
|
|
575
|
+
from .genetic_code import GeneticCode
|
|
576
|
+
|
|
577
|
+
code = GeneticCode.from_ncbi(1)
|
|
578
|
+
codon_rows = [
|
|
579
|
+
(amino_acid, codon)
|
|
580
|
+
for amino_acid, family in sorted(code.synonymous_codons.items())
|
|
581
|
+
if amino_acid != "*" and len(family) > 1
|
|
582
|
+
for codon in sorted(family)
|
|
583
|
+
]
|
|
584
|
+
groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
|
|
585
|
+
figure, axes = plt.subplots(
|
|
586
|
+
len(groups),
|
|
587
|
+
1,
|
|
588
|
+
figsize=(11.0, 2.55 * len(groups)),
|
|
589
|
+
sharex=True,
|
|
590
|
+
constrained_layout=True,
|
|
591
|
+
squeeze=False,
|
|
592
|
+
)
|
|
593
|
+
base_colors = {"A": "#E69F00", "C": "#0072B2", "G": "#009E73", "T": "#CC79A7"}
|
|
594
|
+
positions = np.arange(len(codon_rows))
|
|
595
|
+
for panel, group in enumerate(groups):
|
|
596
|
+
axis = axes[panel, 0]
|
|
597
|
+
selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
|
|
598
|
+
means = []
|
|
599
|
+
errors = []
|
|
600
|
+
for _, codon in codon_rows:
|
|
601
|
+
values = np.asarray([result.rscu[codon] for result in selected], dtype=float)
|
|
602
|
+
means.append(float(values.mean()))
|
|
603
|
+
errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
|
|
604
|
+
axis.bar(
|
|
605
|
+
positions,
|
|
606
|
+
means,
|
|
607
|
+
yerr=errors,
|
|
608
|
+
color=[base_colors[codon[2]] for _, codon in codon_rows],
|
|
609
|
+
edgecolor="white",
|
|
610
|
+
linewidth=0.2,
|
|
611
|
+
error_kw={"elinewidth": 0.45, "capsize": 1.0, "capthick": 0.45},
|
|
612
|
+
)
|
|
613
|
+
axis.axhline(0.6, color="#666666", linestyle=":", linewidth=0.8)
|
|
614
|
+
axis.axhline(1.6, color="#B2182B", linestyle="--", linewidth=0.8)
|
|
615
|
+
axis.set_ylabel("Mean RSCU")
|
|
616
|
+
axis.set_title(f"Genotype {group} (n={len(selected)})", loc="left", fontweight="bold")
|
|
617
|
+
self._clean_axes(axis)
|
|
618
|
+
family_starts: dict[str, int] = {}
|
|
619
|
+
for index, (amino_acid, _) in enumerate(codon_rows):
|
|
620
|
+
family_starts.setdefault(amino_acid, index)
|
|
621
|
+
boundaries = [index - 0.5 for index in family_starts.values() if index]
|
|
622
|
+
for boundary in boundaries:
|
|
623
|
+
axis.axvline(boundary, color="#D9D9D9", linewidth=0.4, zorder=0)
|
|
624
|
+
axes[-1, 0].set_xticks(positions, [codon for _, codon in codon_rows], rotation=90)
|
|
625
|
+
axes[-1, 0].set_xlabel("Codon (bar color indicates third nucleotide)")
|
|
626
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
627
|
+
return figure
|
|
628
|
+
|
|
629
|
+
def dinucleotide_profiles(
|
|
630
|
+
self,
|
|
631
|
+
results: list[AnalysisResult],
|
|
632
|
+
*,
|
|
633
|
+
group_key: str,
|
|
634
|
+
title: str = "Dinucleotide observed/expected profiles",
|
|
635
|
+
) -> Any:
|
|
636
|
+
"""Plot all 16 group-mean dinucleotide O/E ratios with SEM bars."""
|
|
637
|
+
|
|
638
|
+
if not results:
|
|
639
|
+
raise ValueError("results must be non-empty.")
|
|
640
|
+
plt, np = self._matplotlib()
|
|
641
|
+
pairs = [left + right for left in "ACGT" for right in "ACGT"]
|
|
642
|
+
groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
|
|
643
|
+
figure, axis = plt.subplots(figsize=(8.2, 3.9), constrained_layout=True)
|
|
644
|
+
positions = np.arange(len(pairs), dtype=float)
|
|
645
|
+
offsets = np.linspace(-0.18, 0.18, len(groups)) if len(groups) > 1 else np.asarray([0.0])
|
|
646
|
+
for index, (group, offset) in enumerate(zip(groups, offsets, strict=True)):
|
|
647
|
+
selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
|
|
648
|
+
means = []
|
|
649
|
+
errors = []
|
|
650
|
+
for pair in pairs:
|
|
651
|
+
values = np.asarray(
|
|
652
|
+
[
|
|
653
|
+
result.dinucleotide_odds[pair]
|
|
654
|
+
for result in selected
|
|
655
|
+
if result.dinucleotide_odds[pair] is not None
|
|
656
|
+
],
|
|
657
|
+
dtype=float,
|
|
658
|
+
)
|
|
659
|
+
means.append(float(values.mean()))
|
|
660
|
+
errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
|
|
661
|
+
axis.errorbar(
|
|
662
|
+
positions + offset,
|
|
663
|
+
means,
|
|
664
|
+
yerr=errors,
|
|
665
|
+
color=self.COLORS[index % len(self.COLORS)],
|
|
666
|
+
marker=self.MARKERS[index % len(self.MARKERS)],
|
|
667
|
+
markersize=4,
|
|
668
|
+
linewidth=1.0,
|
|
669
|
+
capsize=2,
|
|
670
|
+
label=f"{group} (n={len(selected)})",
|
|
671
|
+
)
|
|
672
|
+
axis.axhline(1.25, color="#B2182B", linestyle="--", linewidth=0.9, label="Overrepresented (>1.25)")
|
|
673
|
+
axis.axhline(0.78, color="#666666", linestyle=":", linewidth=0.9, label="Underrepresented (<0.78)")
|
|
674
|
+
axis.set_xticks(positions, pairs)
|
|
675
|
+
axis.set(xlabel="Dinucleotide", ylabel="Observed/expected ratio", title=title)
|
|
676
|
+
axis.legend(frameon=False, ncol=3, loc="upper center", bbox_to_anchor=(0.5, -0.18))
|
|
677
|
+
self._clean_axes(axis)
|
|
678
|
+
return figure
|
|
679
|
+
|
|
680
|
+
def composition_overview(
|
|
681
|
+
self,
|
|
682
|
+
results: list[AnalysisResult],
|
|
683
|
+
*,
|
|
684
|
+
group_key: str,
|
|
685
|
+
title: str = "Nucleotide composition and codon bias",
|
|
686
|
+
) -> Any:
|
|
687
|
+
"""Create four panels summarizing composition, positional bias, and ENC."""
|
|
688
|
+
|
|
689
|
+
if not results:
|
|
690
|
+
raise ValueError("results must be non-empty.")
|
|
691
|
+
plt, np = self._matplotlib()
|
|
692
|
+
groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
|
|
693
|
+
figure, axes = plt.subplots(2, 2, figsize=(8.2, 6.5))
|
|
694
|
+
figure.subplots_adjust(left=0.09, right=0.985, top=0.87, bottom=0.18, hspace=0.50, wspace=0.30)
|
|
695
|
+
|
|
696
|
+
def significance_label(samples: list[list[float]]) -> str:
|
|
697
|
+
"""Return an overall Kruskal-Wallis significance label when possible."""
|
|
698
|
+
|
|
699
|
+
non_empty = [sample for sample in samples if sample]
|
|
700
|
+
if len(non_empty) < 2:
|
|
701
|
+
return ""
|
|
702
|
+
try:
|
|
703
|
+
from scipy.stats import kruskal
|
|
704
|
+
|
|
705
|
+
p_value = float(kruskal(*non_empty).pvalue)
|
|
706
|
+
except (ImportError, ValueError):
|
|
707
|
+
return ""
|
|
708
|
+
if p_value < 0.0001:
|
|
709
|
+
return "****"
|
|
710
|
+
if p_value < 0.001:
|
|
711
|
+
return "***"
|
|
712
|
+
if p_value < 0.01:
|
|
713
|
+
return "**"
|
|
714
|
+
if p_value < 0.05:
|
|
715
|
+
return "*"
|
|
716
|
+
return "ns"
|
|
717
|
+
|
|
718
|
+
def grouped_bars(axis: Any, keys: list[str], labels: list[str], ylabel: str) -> None:
|
|
719
|
+
positions = np.arange(len(keys), dtype=float)
|
|
720
|
+
width = 0.72 / len(groups)
|
|
721
|
+
samples_by_key: dict[str, list[list[float]]] = {key: [] for key in keys}
|
|
722
|
+
upper_by_key: dict[str, float] = {key: 0.0 for key in keys}
|
|
723
|
+
for group_index, group in enumerate(groups):
|
|
724
|
+
selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
|
|
725
|
+
means = []
|
|
726
|
+
errors = []
|
|
727
|
+
for key in keys:
|
|
728
|
+
values = np.asarray([float(result.metrics[key]) * 100 for result in selected], dtype=float)
|
|
729
|
+
means.append(float(values.mean()))
|
|
730
|
+
errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
|
|
731
|
+
samples_by_key[key].append(values.tolist())
|
|
732
|
+
upper_by_key[key] = max(upper_by_key[key], means[-1] + errors[-1])
|
|
733
|
+
offset = (group_index - (len(groups) - 1) / 2) * width
|
|
734
|
+
axis.bar(
|
|
735
|
+
positions + offset,
|
|
736
|
+
means,
|
|
737
|
+
width=width,
|
|
738
|
+
yerr=errors,
|
|
739
|
+
color=self.COLORS[group_index % len(self.COLORS)],
|
|
740
|
+
alpha=0.82,
|
|
741
|
+
error_kw={"elinewidth": 0.7, "capsize": 2},
|
|
742
|
+
label=f"{group} (n={len(selected)})",
|
|
743
|
+
)
|
|
744
|
+
span = max(upper_by_key.values(), default=1.0)
|
|
745
|
+
for position, key in zip(positions, keys, strict=True):
|
|
746
|
+
label = significance_label(samples_by_key[key])
|
|
747
|
+
if label:
|
|
748
|
+
axis.text(position, upper_by_key[key] + span * 0.035, label, ha="center", va="bottom", fontsize=7)
|
|
749
|
+
axis.set_xticks(positions, labels)
|
|
750
|
+
axis.set_ylabel(ylabel)
|
|
751
|
+
self._clean_axes(axis)
|
|
752
|
+
|
|
753
|
+
grouped_bars(axes[0, 0], ["a", "t", "g", "c"], ["A", "T", "G", "C"], "Composition (%)")
|
|
754
|
+
axes[0, 0].set_title("Overall nucleotide composition")
|
|
755
|
+
grouped_bars(axes[0, 1], ["at1", "at2", "at3"], ["AT1", "AT2", "AT3"], "AT content (%)")
|
|
756
|
+
axes[0, 1].set_title("Codon-position AT content")
|
|
757
|
+
grouped_bars(axes[1, 0], ["gc", "gc3", "gc3s"], ["GC", "GC3", "GC3s"], "GC content (%)")
|
|
758
|
+
axes[1, 0].set_title("GC composition")
|
|
759
|
+
|
|
760
|
+
random = np.random.default_rng(1)
|
|
761
|
+
enc_axis = axes[1, 1]
|
|
762
|
+
enc_values = [
|
|
763
|
+
[result.metrics["enc"] for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
|
|
764
|
+
for group in groups
|
|
765
|
+
]
|
|
766
|
+
boxes = enc_axis.boxplot(enc_values, patch_artist=True, widths=0.55, showfliers=False)
|
|
767
|
+
for index, patch in enumerate(boxes["boxes"]):
|
|
768
|
+
patch.set_facecolor(self.COLORS[index % len(self.COLORS)])
|
|
769
|
+
patch.set_alpha(0.34)
|
|
770
|
+
for index, values in enumerate(enc_values, start=1):
|
|
771
|
+
enc_axis.scatter(
|
|
772
|
+
np.full(len(values), index) + random.uniform(-0.11, 0.11, len(values)),
|
|
773
|
+
values,
|
|
774
|
+
color=self.COLORS[(index - 1) % len(self.COLORS)],
|
|
775
|
+
s=16,
|
|
776
|
+
alpha=0.68,
|
|
777
|
+
edgecolor="white",
|
|
778
|
+
linewidth=0.3,
|
|
779
|
+
)
|
|
780
|
+
enc_axis.set_xticks(
|
|
781
|
+
range(1, len(groups) + 1),
|
|
782
|
+
[f"{group}\n(n={len(values)})" for group, values in zip(groups, enc_values, strict=True)],
|
|
783
|
+
)
|
|
784
|
+
enc_axis.set(xlabel="Genotype", ylabel="Effective number of codons", title="Codon usage bias")
|
|
785
|
+
enc_label = significance_label([[float(value) for value in values] for values in enc_values])
|
|
786
|
+
if enc_label:
|
|
787
|
+
enc_upper = max((max(values) for values in enc_values if values), default=1.0)
|
|
788
|
+
enc_annotation_y = enc_upper + max(enc_upper, 1.0) * 0.035
|
|
789
|
+
enc_axis.text(
|
|
790
|
+
(len(groups) + 1) / 2,
|
|
791
|
+
enc_annotation_y,
|
|
792
|
+
enc_label,
|
|
793
|
+
ha="center",
|
|
794
|
+
va="bottom",
|
|
795
|
+
fontsize=7,
|
|
796
|
+
)
|
|
797
|
+
enc_axis.set_ylim(top=enc_annotation_y + max(enc_upper, 1.0) * 0.05)
|
|
798
|
+
self._clean_axes(enc_axis)
|
|
799
|
+
handles, labels = axes[0, 0].get_legend_handles_labels()
|
|
800
|
+
figure.legend(
|
|
801
|
+
handles,
|
|
802
|
+
labels,
|
|
803
|
+
loc="lower center",
|
|
804
|
+
bbox_to_anchor=(0.5, 0.055),
|
|
805
|
+
ncol=len(groups),
|
|
806
|
+
frameon=False,
|
|
807
|
+
)
|
|
808
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
809
|
+
figure.text(
|
|
810
|
+
0.995,
|
|
811
|
+
0.012,
|
|
812
|
+
"Overall Kruskal-Wallis: ns ≥ 0.05; * < 0.05; ** < 0.01; *** < 0.001; **** < 0.0001",
|
|
813
|
+
ha="right",
|
|
814
|
+
va="bottom",
|
|
815
|
+
fontsize=6,
|
|
816
|
+
color="#4D4D4D",
|
|
817
|
+
)
|
|
818
|
+
for index, axis in enumerate(axes.flat):
|
|
819
|
+
axis.text(-0.14, 1.07, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
|
|
820
|
+
return figure
|
|
821
|
+
|
|
822
|
+
def lda_evaluation(
|
|
823
|
+
self,
|
|
824
|
+
analysis: dict[str, Any],
|
|
825
|
+
groups: list[str],
|
|
826
|
+
*,
|
|
827
|
+
title: str = "Linear discriminant analysis",
|
|
828
|
+
) -> Any:
|
|
829
|
+
"""Plot an LDA projection beside its held-out confusion matrix."""
|
|
830
|
+
|
|
831
|
+
projection = analysis.get("projection", [])
|
|
832
|
+
if not projection or len(projection) != len(groups):
|
|
833
|
+
raise ValueError("LDA projection and groups must have equal non-zero lengths.")
|
|
834
|
+
plt, np = self._matplotlib()
|
|
835
|
+
figure, axes = plt.subplots(1, 2, figsize=(8.2, 3.8), constrained_layout=True)
|
|
836
|
+
classes = [str(value) for value in analysis["classes"]]
|
|
837
|
+
coordinates = np.asarray(projection, dtype=float)
|
|
838
|
+
if coordinates.shape[1] == 1:
|
|
839
|
+
coordinates = np.column_stack([coordinates[:, 0], np.zeros(len(coordinates))])
|
|
840
|
+
for index, group in enumerate(classes):
|
|
841
|
+
selected = np.asarray([position for position, value in enumerate(groups) if str(value) == group])
|
|
842
|
+
axes[0].scatter(
|
|
843
|
+
coordinates[selected, 0],
|
|
844
|
+
coordinates[selected, 1],
|
|
845
|
+
color=self.COLORS[index % len(self.COLORS)],
|
|
846
|
+
marker=self.MARKERS[index % len(self.MARKERS)],
|
|
847
|
+
s=28,
|
|
848
|
+
alpha=0.78,
|
|
849
|
+
edgecolor="white",
|
|
850
|
+
linewidth=0.4,
|
|
851
|
+
label=f"{group} (n={len(selected)})",
|
|
852
|
+
)
|
|
853
|
+
axes[0].axhline(0, color="#BDBDBD", linewidth=0.6)
|
|
854
|
+
axes[0].axvline(0, color="#BDBDBD", linewidth=0.6)
|
|
855
|
+
axes[0].set(xlabel="LD1", ylabel="LD2", title="All-sample projection")
|
|
856
|
+
axes[0].legend(frameon=False, title="Genotype")
|
|
857
|
+
self._clean_axes(axes[0])
|
|
858
|
+
|
|
859
|
+
matrix = np.asarray(analysis["confusion_matrix"], dtype=float)
|
|
860
|
+
image = axes[1].imshow(matrix, cmap="Blues", vmin=0)
|
|
861
|
+
axes[1].set_xticks(range(len(classes)), classes)
|
|
862
|
+
axes[1].set_yticks(range(len(classes)), classes)
|
|
863
|
+
axes[1].set(
|
|
864
|
+
xlabel="Predicted genotype",
|
|
865
|
+
ylabel="True genotype",
|
|
866
|
+
title=f"Held-out accuracy: {analysis['accuracy'] * 100:.1f}%",
|
|
867
|
+
)
|
|
868
|
+
for row in range(matrix.shape[0]):
|
|
869
|
+
for column in range(matrix.shape[1]):
|
|
870
|
+
axes[1].text(column, row, f"{int(matrix[row, column])}", ha="center", va="center", fontsize=9)
|
|
871
|
+
figure.colorbar(image, ax=axes[1], fraction=0.045, pad=0.03, label="Test samples")
|
|
872
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
873
|
+
for index, axis in enumerate(axes):
|
|
874
|
+
axis.text(-0.16, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
|
|
875
|
+
return figure
|
|
876
|
+
|
|
877
|
+
def host_adaptation(
|
|
878
|
+
self,
|
|
879
|
+
rows: list[dict[str, Any]],
|
|
880
|
+
*,
|
|
881
|
+
metrics: list[str],
|
|
882
|
+
group_key: str,
|
|
883
|
+
host_key: str,
|
|
884
|
+
title: str = "Host adaptation",
|
|
885
|
+
) -> Any:
|
|
886
|
+
"""Plot host metrics by genotype with observations, means, and SEM."""
|
|
887
|
+
|
|
888
|
+
if not rows or not metrics:
|
|
889
|
+
raise ValueError("rows and metrics must be non-empty.")
|
|
890
|
+
plt, np = self._matplotlib()
|
|
891
|
+
groups = sorted({str(row[group_key]) for row in rows})
|
|
892
|
+
hosts = list(dict.fromkeys(str(row[host_key]) for row in rows))
|
|
893
|
+
figure, axes = plt.subplots(1, len(metrics), figsize=(4.4 * len(metrics), 3.8), constrained_layout=True)
|
|
894
|
+
axes = np.atleast_1d(axes)
|
|
895
|
+
random = np.random.default_rng(1)
|
|
896
|
+
host_positions = np.arange(len(hosts), dtype=float)
|
|
897
|
+
offsets = np.linspace(-0.22, 0.22, len(groups)) if len(groups) > 1 else np.asarray([0.0])
|
|
898
|
+
for panel, metric in enumerate(metrics):
|
|
899
|
+
axis = axes[panel]
|
|
900
|
+
for index, (group, offset) in enumerate(zip(groups, offsets, strict=True)):
|
|
901
|
+
means = []
|
|
902
|
+
errors = []
|
|
903
|
+
for host_position, host in enumerate(hosts):
|
|
904
|
+
values = np.asarray(
|
|
905
|
+
[
|
|
906
|
+
float(row[metric])
|
|
907
|
+
for row in rows
|
|
908
|
+
if str(row[group_key]) == group
|
|
909
|
+
and str(row[host_key]) == host
|
|
910
|
+
and row.get(metric) is not None
|
|
911
|
+
],
|
|
912
|
+
dtype=float,
|
|
913
|
+
)
|
|
914
|
+
means.append(float(values.mean()))
|
|
915
|
+
errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
|
|
916
|
+
axis.scatter(
|
|
917
|
+
np.full(len(values), host_position + offset) + random.uniform(-0.035, 0.035, len(values)),
|
|
918
|
+
values,
|
|
919
|
+
color=self.COLORS[index % len(self.COLORS)],
|
|
920
|
+
s=10,
|
|
921
|
+
alpha=0.25,
|
|
922
|
+
linewidth=0,
|
|
923
|
+
)
|
|
924
|
+
axis.errorbar(
|
|
925
|
+
host_positions + offset,
|
|
926
|
+
means,
|
|
927
|
+
yerr=errors,
|
|
928
|
+
color=self.COLORS[index % len(self.COLORS)],
|
|
929
|
+
marker=self.MARKERS[index % len(self.MARKERS)],
|
|
930
|
+
linewidth=1.0,
|
|
931
|
+
markersize=5,
|
|
932
|
+
capsize=2,
|
|
933
|
+
label=f"{group}",
|
|
934
|
+
)
|
|
935
|
+
axis.set_xticks(host_positions, hosts, rotation=25, ha="right")
|
|
936
|
+
axis.set(xlabel="Host", ylabel=self.METRIC_LABELS.get(metric.lower(), metric), title=metric)
|
|
937
|
+
self._clean_axes(axis)
|
|
938
|
+
axis.text(-0.15, 1.08, chr(65 + panel), transform=axis.transAxes, fontweight="bold", fontsize=11)
|
|
939
|
+
axes[0].legend(frameon=False, title="Genotype")
|
|
940
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
941
|
+
return figure
|
|
942
|
+
|
|
943
|
+
def bland_altman_validation(
|
|
944
|
+
self,
|
|
945
|
+
panels: list[dict[str, Any]],
|
|
946
|
+
*,
|
|
947
|
+
title: str = "Method Validation: Bland-Altman and Residual Agreement",
|
|
948
|
+
) -> Any:
|
|
949
|
+
"""Create multi-panel Bland-Altman and residual agreement plots.
|
|
950
|
+
|
|
951
|
+
Each dictionary in ``panels`` must supply:
|
|
952
|
+
- ``metric``: str metric label (e.g. 'CAI', 'ENC', 'RSCU', 'GC3s')
|
|
953
|
+
- ``reference``: list[float] reference or benchmark values
|
|
954
|
+
- ``tool``: list[float] CodonAdaptPy calculated values
|
|
955
|
+
"""
|
|
956
|
+
|
|
957
|
+
if not panels:
|
|
958
|
+
raise ValueError("panels must be non-empty.")
|
|
959
|
+
plt, np = self._matplotlib()
|
|
960
|
+
columns = min(2, len(panels))
|
|
961
|
+
rows = math.ceil(len(panels) / columns)
|
|
962
|
+
figure, axes = plt.subplots(
|
|
963
|
+
rows,
|
|
964
|
+
columns,
|
|
965
|
+
figsize=(7.2, 3.2 * rows),
|
|
966
|
+
constrained_layout=True,
|
|
967
|
+
squeeze=False,
|
|
968
|
+
)
|
|
969
|
+
for index, panel in enumerate(panels):
|
|
970
|
+
axis = axes.flat[index]
|
|
971
|
+
ref = np.asarray(panel["reference"], dtype=float)
|
|
972
|
+
tool = np.asarray(panel["tool"], dtype=float)
|
|
973
|
+
if len(ref) != len(tool) or len(ref) < 3:
|
|
974
|
+
raise ValueError("reference and tool must contain at least three matching observations.")
|
|
975
|
+
means = (ref + tool) / 2.0
|
|
976
|
+
diffs = tool - ref
|
|
977
|
+
bias = float(diffs.mean())
|
|
978
|
+
sd = float(diffs.std(ddof=1)) if len(diffs) > 1 else 0.0
|
|
979
|
+
upper_loa = bias + 1.96 * sd
|
|
980
|
+
lower_loa = bias - 1.96 * sd
|
|
981
|
+
|
|
982
|
+
correlation = np.corrcoef(ref, tool)[0, 1] if len(ref) > 1 else 1.0
|
|
983
|
+
r_squared = float(correlation**2)
|
|
984
|
+
|
|
985
|
+
axis.scatter(means, diffs, s=16, color="#0072B2", alpha=0.7, edgecolor="white", linewidth=0.3)
|
|
986
|
+
axis.axhline(bias, color="#D55E00", linewidth=1.2, linestyle="-", label=f"Mean bias: {bias:+.4f}")
|
|
987
|
+
axis.axhline(upper_loa, color="#009E73", linewidth=1.0, linestyle="--", label=f"+1.96 SD: {upper_loa:+.4f}")
|
|
988
|
+
axis.axhline(lower_loa, color="#009E73", linewidth=1.0, linestyle="--", label=f"-1.96 SD: {lower_loa:+.4f}")
|
|
989
|
+
|
|
990
|
+
metric_name = panel.get("metric", f"Metric {index + 1}")
|
|
991
|
+
axis.set_xlabel(f"Mean ({metric_name})")
|
|
992
|
+
axis.set_ylabel("Difference (CodonAdaptPy - Ref)")
|
|
993
|
+
axis.set_title(f"{metric_name} (R² = {r_squared:.4f}, n = {len(ref)})")
|
|
994
|
+
axis.legend(frameon=False, fontsize=6.5, loc="upper right")
|
|
995
|
+
self._clean_axes(axis)
|
|
996
|
+
|
|
997
|
+
for axis in axes.flat[len(panels) :]:
|
|
998
|
+
axis.set_visible(False)
|
|
999
|
+
|
|
1000
|
+
figure.suptitle(title, fontsize=11, fontweight="bold")
|
|
1001
|
+
for index, axis in enumerate(axes.flat[: len(panels)]):
|
|
1002
|
+
axis.text(-0.15, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
|
|
1003
|
+
return figure
|
|
1004
|
+
|
|
1005
|
+
def repeated_cv_distribution(
|
|
1006
|
+
self,
|
|
1007
|
+
lda_output: dict[str, Any],
|
|
1008
|
+
*,
|
|
1009
|
+
title: str = "Repeated Grouped Cross-Validation Distributions",
|
|
1010
|
+
) -> Any:
|
|
1011
|
+
"""Plot fold-level accuracy distributions across repeated CV iterations."""
|
|
1012
|
+
|
|
1013
|
+
fold_results = lda_output.get("fold_results", [])
|
|
1014
|
+
if not fold_results:
|
|
1015
|
+
raise ValueError("lda_output must contain non-empty 'fold_results'.")
|
|
1016
|
+
plt, np = self._matplotlib()
|
|
1017
|
+
figure, axis = plt.subplots(figsize=(6.5, 3.6), constrained_layout=True)
|
|
1018
|
+
|
|
1019
|
+
metrics = [("accuracy", "Accuracy"), ("balanced_accuracy", "Balanced Accuracy"), ("macro_f1", "Macro F1")]
|
|
1020
|
+
positions = np.arange(len(metrics))
|
|
1021
|
+
data = [[float(row[key]) * 100 for row in fold_results] for key, _ in metrics]
|
|
1022
|
+
|
|
1023
|
+
boxes = axis.boxplot(data, positions=positions, patch_artist=True, widths=0.45, showfliers=False)
|
|
1024
|
+
for idx, patch in enumerate(boxes["boxes"]):
|
|
1025
|
+
patch.set_facecolor(self.COLORS[idx % len(self.COLORS)])
|
|
1026
|
+
patch.set_alpha(0.4)
|
|
1027
|
+
patch.set_edgecolor(self.COLORS[idx % len(self.COLORS)])
|
|
1028
|
+
|
|
1029
|
+
random = np.random.default_rng(1)
|
|
1030
|
+
for idx, values in enumerate(data):
|
|
1031
|
+
jitter = random.uniform(-0.1, 0.1, len(values))
|
|
1032
|
+
axis.scatter(
|
|
1033
|
+
positions[idx] + jitter,
|
|
1034
|
+
values,
|
|
1035
|
+
s=14,
|
|
1036
|
+
color=self.COLORS[idx % len(self.COLORS)],
|
|
1037
|
+
alpha=0.6,
|
|
1038
|
+
edgecolor="white",
|
|
1039
|
+
linewidth=0.3,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
summary = lda_output.get("summary", {})
|
|
1043
|
+
for idx, (key, _metric_label) in enumerate(metrics):
|
|
1044
|
+
if key in summary:
|
|
1045
|
+
mean_val = summary[key]["mean"] * 100
|
|
1046
|
+
sd_val = summary[key]["sd"] * 100
|
|
1047
|
+
axis.text(
|
|
1048
|
+
positions[idx],
|
|
1049
|
+
min([min(d) for d in data]) - 3.0,
|
|
1050
|
+
f"Mean: {mean_val:.1f}%\nSD: ±{sd_val:.1f}%",
|
|
1051
|
+
ha="center",
|
|
1052
|
+
va="top",
|
|
1053
|
+
fontsize=7,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
axis.set_xticks(positions, [label for _, label in metrics])
|
|
1057
|
+
axis.set_ylabel("Held-Out Performance (%)")
|
|
1058
|
+
folds = lda_output.get("folds", 5)
|
|
1059
|
+
repeats = lda_output.get("repeats", 20)
|
|
1060
|
+
subtitle = f"({folds}-fold Stratified Grouped LDA, {repeats} repeats, n={len(fold_results)} folds)"
|
|
1061
|
+
axis.set_title(f"{title}\n{subtitle}")
|
|
1062
|
+
self._clean_axes(axis)
|
|
1063
|
+
return figure
|
|
1064
|
+
|
|
1065
|
+
def save(self, figure: Any, path: str | Path, *, dpi: int = 600) -> Path:
|
|
1066
|
+
"""Save a publication figure as PNG, TIFF, PDF, or SVG."""
|
|
1067
|
+
|
|
1068
|
+
destination = Path(path)
|
|
1069
|
+
if destination.suffix.lower() not in {".png", ".tif", ".tiff", ".pdf", ".svg"}:
|
|
1070
|
+
raise ValueError("Publication figures must use PNG, TIFF, PDF, or SVG.")
|
|
1071
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1072
|
+
figure.savefig(destination, dpi=dpi, bbox_inches="tight", facecolor="white")
|
|
1073
|
+
return destination
|