agingclockbench 0.1.0__tar.gz → 0.2.0__tar.gz
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.
Potentially problematic release.
This version of agingclockbench might be problematic. Click here for more details.
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/PKG-INFO +6 -5
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/README.md +2 -2
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/pyproject.toml +4 -3
- agingclockbench-0.2.0/src/agingclockbench/benchmarks/altair_plots.py +659 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/plots.py +41 -41
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/LICENSE +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/__init__.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/__init__.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/metrics.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/suite.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/cli.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/__init__.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/base.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/dunedinpace.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/kdm.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/phenoage.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/config.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/__init__.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/loaders.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/nhanes_sample.parquet +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/utils/__init__.py +0 -0
- {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/utils/validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agingclockbench
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Benchmark biological aging clocks on your data — PhenoAge, KDM, DunedinPACE proxy
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
19
|
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
20
20
|
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
21
|
+
Requires-Dist: altair (>=5.0.0)
|
|
21
22
|
Requires-Dist: click (>=8.1.0)
|
|
22
23
|
Requires-Dist: lifelines (>=0.28.0)
|
|
23
24
|
Requires-Dist: matplotlib (>=3.8.0)
|
|
@@ -28,8 +29,8 @@ Requires-Dist: pyarrow (>=13.0.0)
|
|
|
28
29
|
Requires-Dist: pydantic (>=2.3.0)
|
|
29
30
|
Requires-Dist: scipy (>=1.13.0)
|
|
30
31
|
Requires-Dist: seaborn (>=0.13.0)
|
|
31
|
-
Project-URL: Documentation, https://aadityageddam-ux
|
|
32
|
-
Project-URL: Homepage, https://aadityageddam-ux
|
|
32
|
+
Project-URL: Documentation, https://github.com/aadityageddam-ux/aging_clock_bench#quick-start
|
|
33
|
+
Project-URL: Homepage, https://github.com/aadityageddam-ux/aging_clock_bench
|
|
33
34
|
Project-URL: Repository, https://github.com/aadityageddam-ux/aging_clock_bench
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
@@ -38,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
38
39
|
[](https://badge.fury.io/py/agingclockbench)
|
|
39
40
|
[](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml)
|
|
40
41
|
[](https://github.com/aadityageddam-ux/aging_clock_bench)
|
|
41
|
-
[](https://github.com/aadityageddam-ux/aging_clock_bench#quick-start)
|
|
42
43
|
[](https://opensource.org/licenses/MIT)
|
|
43
44
|
[](https://www.python.org/downloads/)
|
|
44
45
|
|
|
@@ -46,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
46
47
|
|
|
47
48
|
Multiple biological aging clocks exist — PhenoAge, KDM, DunedinPACE — but no standard tool lets researchers compare them side-by-side. AgingClockBench is the **first open-source Python package** implementing multiple clocks with a unified interface and reproducible mortality-validated benchmarking.
|
|
48
49
|
|
|
49
|
-
📖 **[
|
|
50
|
+
📖 **[Documentation & Examples](#quick-start)**
|
|
50
51
|
|
|
51
52
|
---
|
|
52
53
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://badge.fury.io/py/agingclockbench)
|
|
4
4
|
[](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml)
|
|
5
5
|
[](https://github.com/aadityageddam-ux/aging_clock_bench)
|
|
6
|
-
[](https://github.com/aadityageddam-ux/aging_clock_bench#quick-start)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://www.python.org/downloads/)
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
Multiple biological aging clocks exist — PhenoAge, KDM, DunedinPACE — but no standard tool lets researchers compare them side-by-side. AgingClockBench is the **first open-source Python package** implementing multiple clocks with a unified interface and reproducible mortality-validated benchmarking.
|
|
13
13
|
|
|
14
|
-
📖 **[
|
|
14
|
+
📖 **[Documentation & Examples](#quick-start)**
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "agingclockbench"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Benchmark biological aging clocks on your data — PhenoAge, KDM, DunedinPACE proxy"
|
|
5
5
|
authors = ["Aaditya Geddam <aaditya.geddam@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
|
-
homepage = "https://aadityageddam-ux
|
|
8
|
+
homepage = "https://github.com/aadityageddam-ux/aging_clock_bench"
|
|
9
9
|
repository = "https://github.com/aadityageddam-ux/aging_clock_bench"
|
|
10
|
-
documentation = "https://aadityageddam-ux
|
|
10
|
+
documentation = "https://github.com/aadityageddam-ux/aging_clock_bench#quick-start"
|
|
11
11
|
keywords = ["aging", "biomarkers", "aging-clocks", "longevity", "biological-age",
|
|
12
12
|
"phenoage", "kdm", "nhanes", "mortality"]
|
|
13
13
|
classifiers = [
|
|
@@ -31,6 +31,7 @@ scipy = ">=1.13.0"
|
|
|
31
31
|
lifelines = ">=0.28.0"
|
|
32
32
|
pydantic = ">=2.3.0"
|
|
33
33
|
plotly = ">=5.17.0"
|
|
34
|
+
altair = ">=5.0.0"
|
|
34
35
|
seaborn = ">=0.13.0"
|
|
35
36
|
matplotlib = ">=3.8.0"
|
|
36
37
|
click = ">=8.1.0"
|
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Altair-based visualization functions for AgingClockBench reports.
|
|
3
|
+
|
|
4
|
+
Provides production-grade scatter plots with density visualization, statistical
|
|
5
|
+
annotations, and portfolio-quality aesthetics. Designed to replace raw Plotly
|
|
6
|
+
scatter plots which suffer from over-plotting at N=4,000+.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agingclockbench.clocks.base import ClockResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Professional color palette — accessible, distinct at small sizes
|
|
21
|
+
_CLOCK_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
|
|
22
|
+
|
|
23
|
+
# Publication palette: vibrant teal (tight/confident) + bright orange (more variance)
|
|
24
|
+
_PUB_COLORS = {"PhenoAge": "#0277bd", "KDM": "#ff6f00", "DunedinPACEProxy": "#546e7a"}
|
|
25
|
+
_PUB_FALLBACK = ["#0277bd", "#ff6f00", "#546e7a", "#2c3e50"]
|
|
26
|
+
|
|
27
|
+
# Per-clock ellipse style: (hex_color, strokeDash, strokeWidth, opacity)
|
|
28
|
+
# Slot 0 = "better" clock (solid, thick); Slot 1 = comparison clock (dashed, slightly thinner)
|
|
29
|
+
_ELLIPSE_STYLES = [
|
|
30
|
+
("#0277bd", [], 3.5, 0.92), # PhenoAge — teal, solid, prominent
|
|
31
|
+
("#ff6f00", [5, 5], 2.5, 0.88), # KDM — orange, dashed, secondary
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compute_confidence_ellipse(
|
|
36
|
+
x: np.ndarray,
|
|
37
|
+
y: np.ndarray,
|
|
38
|
+
confidence: float = 0.95,
|
|
39
|
+
n_points: int = 120,
|
|
40
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
41
|
+
"""Compute a 2D confidence ellipse for a set of (x, y) points.
|
|
42
|
+
|
|
43
|
+
Uses eigendecomposition of the covariance matrix scaled by the chi-squared
|
|
44
|
+
critical value for the requested confidence level.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
x, y : 1-D arrays of equal length
|
|
49
|
+
confidence : probability mass enclosed (default 0.95 = 95 %)
|
|
50
|
+
n_points : number of boundary points (higher = smoother curve)
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
(ellipse_x, ellipse_y) : boundary coordinates, each length n_points
|
|
55
|
+
"""
|
|
56
|
+
from scipy import stats
|
|
57
|
+
|
|
58
|
+
mean_x, mean_y = float(np.mean(x)), float(np.mean(y))
|
|
59
|
+
cov = np.cov(x, y)
|
|
60
|
+
|
|
61
|
+
# eigh is for symmetric matrices — returns REAL eigenvalues in ascending order
|
|
62
|
+
eigenvalues, eigenvectors = np.linalg.eigh(cov)
|
|
63
|
+
|
|
64
|
+
chi2_val = stats.chi2.ppf(confidence, df=2)
|
|
65
|
+
# eigenvalues are [minor, major] after eigh (ascending order)
|
|
66
|
+
b = np.sqrt(np.abs(eigenvalues[0]) * chi2_val) # minor semi-axis
|
|
67
|
+
a = np.sqrt(np.abs(eigenvalues[1]) * chi2_val) # major semi-axis
|
|
68
|
+
|
|
69
|
+
# Rotation angle from major eigenvector
|
|
70
|
+
angle = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])
|
|
71
|
+
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
|
72
|
+
|
|
73
|
+
theta = np.linspace(0, 2 * np.pi, n_points)
|
|
74
|
+
ex = a * np.cos(theta)
|
|
75
|
+
ey = b * np.sin(theta)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
mean_x + ex * cos_a - ey * sin_a,
|
|
79
|
+
mean_y + ex * sin_a + ey * cos_a,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def generate_publication_chart(
|
|
84
|
+
df: pd.DataFrame,
|
|
85
|
+
summary_df: pd.DataFrame,
|
|
86
|
+
results: dict[str, "ClockResult"],
|
|
87
|
+
top_n_clocks: int = 2,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Publication-grade scatter + 95% confidence ellipses per clock.
|
|
90
|
+
|
|
91
|
+
Designed to make the performance difference between clocks immediately visible:
|
|
92
|
+
a tight ellipse = consistent predictions; a wide ellipse = high variance.
|
|
93
|
+
Uses low-opacity scatter (density implied by stacking) + solid ellipse boundaries.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
df : original DataFrame (must contain 'age' column)
|
|
98
|
+
summary_df : BenchmarkReport.to_dataframe() output
|
|
99
|
+
results : dict mapping clock name -> ClockResult
|
|
100
|
+
top_n_clocks : number of clocks to show (default 2 = PhenoAge + KDM)
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
str — self-contained HTML fragment (Altair chart + stats table + caption)
|
|
105
|
+
"""
|
|
106
|
+
import altair as alt
|
|
107
|
+
|
|
108
|
+
alt.data_transformers.disable_max_rows()
|
|
109
|
+
|
|
110
|
+
# Prefer PhenoAge + KDM (most scientifically distinct clocks)
|
|
111
|
+
_PREFERRED_ORDER = ["PhenoAge", "KDM", "DunedinPACEProxy"]
|
|
112
|
+
available = [c for c in _PREFERRED_ORDER if c in results]
|
|
113
|
+
top_clocks = (
|
|
114
|
+
available[:top_n_clocks] if len(available) >= top_n_clocks
|
|
115
|
+
else list(results.keys())[:top_n_clocks]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Assign colors: teal = tighter clock, rust = wider spread
|
|
119
|
+
color_range = [
|
|
120
|
+
_PUB_COLORS.get(name, _PUB_FALLBACK[i])
|
|
121
|
+
for i, name in enumerate(top_clocks)
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# ── Build scatter data ──────────────────────────────────────────────────
|
|
125
|
+
scatter_frames = []
|
|
126
|
+
ellipse_frames = []
|
|
127
|
+
|
|
128
|
+
for clock_name in top_clocks:
|
|
129
|
+
result = results[clock_name]
|
|
130
|
+
age = (
|
|
131
|
+
df.loc[result.original_index, "age"].values
|
|
132
|
+
if result.original_index is not None
|
|
133
|
+
else df["age"].iloc[: result.output_rows].values
|
|
134
|
+
)
|
|
135
|
+
bio_age = result.biological_ages.values
|
|
136
|
+
|
|
137
|
+
scatter_frames.append(pd.DataFrame({
|
|
138
|
+
"Chronological Age": age.astype(float),
|
|
139
|
+
"Biological Age": bio_age.astype(float),
|
|
140
|
+
"Clock": clock_name,
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
# Compute + store 95% confidence ellipse
|
|
144
|
+
ex, ey = compute_confidence_ellipse(age, bio_age)
|
|
145
|
+
ellipse_frames.append(pd.DataFrame({
|
|
146
|
+
"Chronological Age": ex,
|
|
147
|
+
"Biological Age": ey,
|
|
148
|
+
"Clock": clock_name,
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
scatter_df = pd.concat(scatter_frames, ignore_index=True)
|
|
152
|
+
ellipse_df = pd.concat(ellipse_frames, ignore_index=True)
|
|
153
|
+
|
|
154
|
+
color_enc = alt.Color(
|
|
155
|
+
"Clock:N",
|
|
156
|
+
scale=alt.Scale(domain=top_clocks, range=color_range),
|
|
157
|
+
title="Clock Algorithm",
|
|
158
|
+
legend=alt.Legend(orient="bottom", direction="horizontal"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ── Scatter layer (low opacity = density by stacking) ──────────────────
|
|
162
|
+
scatter_layer = (
|
|
163
|
+
alt.Chart(scatter_df)
|
|
164
|
+
.mark_point(size=18, filled=True, stroke=None)
|
|
165
|
+
.encode(
|
|
166
|
+
x=alt.X("Chronological Age:Q", scale=alt.Scale(zero=False),
|
|
167
|
+
axis=alt.Axis(title="Chronological Age (years)", grid=True,
|
|
168
|
+
gridColor="#ebebeb")),
|
|
169
|
+
y=alt.Y("Biological Age:Q", scale=alt.Scale(zero=False),
|
|
170
|
+
axis=alt.Axis(title="Biological Age (years)", grid=True,
|
|
171
|
+
gridColor="#ebebeb")),
|
|
172
|
+
color=color_enc,
|
|
173
|
+
opacity=alt.value(0.15),
|
|
174
|
+
tooltip=[
|
|
175
|
+
alt.Tooltip("Chronological Age:Q", format=".1f"),
|
|
176
|
+
alt.Tooltip("Biological Age:Q", format=".1f"),
|
|
177
|
+
"Clock:N",
|
|
178
|
+
],
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ── Confidence ellipse layers (one per clock, explicit color + stroke style) ──
|
|
183
|
+
# Splitting into separate layers guarantees each clock gets its own color and
|
|
184
|
+
# stroke pattern — a shared color scale can blend or collapse in Altair.
|
|
185
|
+
# Layer order: KDM first (bottom), PhenoAge on top (more prominent).
|
|
186
|
+
ellipse_layers = []
|
|
187
|
+
for i, clock_name in enumerate(reversed(top_clocks)): # reversed = KDM first
|
|
188
|
+
style_idx = top_clocks.index(clock_name) # keep style aligned to position
|
|
189
|
+
color, dash, width, opacity = _ELLIPSE_STYLES[min(style_idx, len(_ELLIPSE_STYLES) - 1)]
|
|
190
|
+
clock_ellipse_df = ellipse_df[ellipse_df["Clock"] == clock_name]
|
|
191
|
+
layer = (
|
|
192
|
+
alt.Chart(clock_ellipse_df)
|
|
193
|
+
.mark_line(
|
|
194
|
+
strokeDash=dash if dash else alt.Undefined,
|
|
195
|
+
size=width,
|
|
196
|
+
opacity=opacity,
|
|
197
|
+
)
|
|
198
|
+
.encode(
|
|
199
|
+
x=alt.X("Chronological Age:Q", scale=alt.Scale(zero=False)),
|
|
200
|
+
y=alt.Y("Biological Age:Q", scale=alt.Scale(zero=False)),
|
|
201
|
+
color=alt.value(color),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
ellipse_layers.append(layer)
|
|
205
|
+
|
|
206
|
+
# ── Identity line (y = x) ───────────────────────────────────────────────
|
|
207
|
+
age_min = float(scatter_df["Chronological Age"].min())
|
|
208
|
+
age_max = float(scatter_df["Chronological Age"].max())
|
|
209
|
+
identity_df = pd.DataFrame({
|
|
210
|
+
"Chronological Age": [age_min, age_max],
|
|
211
|
+
"Biological Age": [age_min, age_max],
|
|
212
|
+
})
|
|
213
|
+
identity_line = (
|
|
214
|
+
alt.Chart(identity_df)
|
|
215
|
+
.mark_line(strokeDash=[6, 4], color="#444444", size=1.8, opacity=0.7)
|
|
216
|
+
.encode(x="Chronological Age:Q", y="Biological Age:Q")
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# ── Compose layers: scatter → KDM ellipse → PhenoAge ellipse → identity ──
|
|
220
|
+
base = scatter_layer
|
|
221
|
+
for layer in ellipse_layers:
|
|
222
|
+
base = base + layer
|
|
223
|
+
chart = (
|
|
224
|
+
(base + identity_line)
|
|
225
|
+
.properties(
|
|
226
|
+
width=530, height=480,
|
|
227
|
+
title=alt.TitleParams(
|
|
228
|
+
"Biological Age vs Chronological Age — 95% Confidence Ellipses",
|
|
229
|
+
fontSize=14, fontWeight="bold", color="#2c3e50",
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
.configure_view(strokeWidth=0)
|
|
233
|
+
.configure_axis(labelFontSize=11, titleFontSize=12)
|
|
234
|
+
.interactive()
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
chart_html = chart.to_html(embed_options={"actions": False})
|
|
238
|
+
stats_html = _stats_table(summary_df, top_clocks)
|
|
239
|
+
|
|
240
|
+
excluded = [c for c in results if c not in top_clocks]
|
|
241
|
+
note = (
|
|
242
|
+
f'<p style="font-size:12px;color:#888;margin-top:6px;">'
|
|
243
|
+
f"<strong>Note:</strong> {', '.join(excluded)} excluded from scatter. "
|
|
244
|
+
f"Full metrics in the table above.</p>"
|
|
245
|
+
) if excluded else ""
|
|
246
|
+
|
|
247
|
+
return f"""
|
|
248
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
249
|
+
padding:0 0 28px 0;">
|
|
250
|
+
<p style="color:#555;font-size:13px;margin:0 0 10px 0;">
|
|
251
|
+
Points at 15% opacity — darker regions = higher point density.
|
|
252
|
+
Dashed ellipses enclose 95% of each clock's predictions.
|
|
253
|
+
A <em>narrower</em> ellipse means more consistent biological age estimates.
|
|
254
|
+
Dashed black line = perfect prediction (y = x).
|
|
255
|
+
</p>
|
|
256
|
+
{chart_html}
|
|
257
|
+
<h3 style="margin:22px 0 8px 0;color:#2c3e50;">Clock Performance Summary</h3>
|
|
258
|
+
{stats_html}
|
|
259
|
+
{note}
|
|
260
|
+
</div>
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def generate_bland_altman_plots(
|
|
265
|
+
df: pd.DataFrame,
|
|
266
|
+
summary_df: pd.DataFrame,
|
|
267
|
+
results: dict[str, "ClockResult"],
|
|
268
|
+
top_n_clocks: int = 2,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Generate side-by-side Bland-Altman plots for clock validation.
|
|
271
|
+
|
|
272
|
+
Bland-Altman is the standard method-comparison format in biomedical research.
|
|
273
|
+
Shows systematic bias (mean error) and limits of agreement (±1.96 SD) for each
|
|
274
|
+
clock, plotted against mean age to reveal age-dependent heteroscedasticity.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
df : original DataFrame (must contain 'age' column)
|
|
279
|
+
summary_df : BenchmarkReport.to_dataframe() output
|
|
280
|
+
results : dict mapping clock name -> ClockResult
|
|
281
|
+
top_n_clocks : number of clocks to show (default 2 = PhenoAge + KDM)
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
str — self-contained HTML fragment (hconcat Altair chart + stats table + legend)
|
|
286
|
+
"""
|
|
287
|
+
import altair as alt
|
|
288
|
+
|
|
289
|
+
alt.data_transformers.disable_max_rows()
|
|
290
|
+
|
|
291
|
+
# Always prefer PhenoAge + KDM (most scientifically distinct)
|
|
292
|
+
_PREFERRED_ORDER = ["PhenoAge", "KDM", "DunedinPACEProxy"]
|
|
293
|
+
available = [c for c in _PREFERRED_ORDER if c in results]
|
|
294
|
+
top_clocks = (
|
|
295
|
+
available[:top_n_clocks] if len(available) >= top_n_clocks
|
|
296
|
+
else list(results.keys())[:top_n_clocks]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# ── Per-clock computation (Python, not Altair transforms) ─────────────
|
|
300
|
+
clock_charts = []
|
|
301
|
+
interp_rows = [] # for the interpretation block below the chart
|
|
302
|
+
|
|
303
|
+
for i, clock_name in enumerate(top_clocks):
|
|
304
|
+
result = results[clock_name]
|
|
305
|
+
age = (
|
|
306
|
+
df.loc[result.original_index, "age"].values
|
|
307
|
+
if result.original_index is not None
|
|
308
|
+
else df["age"].iloc[: result.output_rows].values
|
|
309
|
+
).astype(float)
|
|
310
|
+
bio = result.biological_ages.values.astype(float)
|
|
311
|
+
|
|
312
|
+
mean_age = (age + bio) / 2.0
|
|
313
|
+
diff = bio - age
|
|
314
|
+
|
|
315
|
+
# Stats computed on FULL data (standard BA practice)
|
|
316
|
+
mean_bias = float(np.mean(diff))
|
|
317
|
+
upper_loa = mean_bias + 1.96 * float(np.std(diff, ddof=1))
|
|
318
|
+
lower_loa = mean_bias - 1.96 * float(np.std(diff, ddof=1))
|
|
319
|
+
|
|
320
|
+
# X-axis domain: pin to 1st–99th percentile of chronological age
|
|
321
|
+
# so extreme bio-age outliers (KDM can predict 200+ yrs) don't blow the scale
|
|
322
|
+
x_lo = float(np.percentile(age, 1))
|
|
323
|
+
x_hi = float(np.percentile(age, 99))
|
|
324
|
+
|
|
325
|
+
color = _PUB_COLORS.get(clock_name, _PUB_FALLBACK[i])
|
|
326
|
+
|
|
327
|
+
# Scatter data — clip to x-axis domain (stats already computed on full data)
|
|
328
|
+
all_pts = pd.DataFrame({
|
|
329
|
+
"Mean Age": mean_age,
|
|
330
|
+
"Prediction Error": diff,
|
|
331
|
+
})
|
|
332
|
+
pts_df = all_pts[(all_pts["Mean Age"] >= x_lo) & (all_pts["Mean Age"] <= x_hi)]
|
|
333
|
+
|
|
334
|
+
# Reference lines — single-row DataFrames so Altair doesn't embed 4K rows per rule
|
|
335
|
+
bias_df = pd.DataFrame({"y": [mean_bias]})
|
|
336
|
+
upper_df = pd.DataFrame({"y": [upper_loa]})
|
|
337
|
+
lower_df = pd.DataFrame({"y": [lower_loa]})
|
|
338
|
+
zero_df = pd.DataFrame({"y": [0.0]})
|
|
339
|
+
|
|
340
|
+
# Shared x/y encoding for scatter
|
|
341
|
+
# x: fixed to chronological age range (clips extreme bio-age outliers)
|
|
342
|
+
# y: fixed domain so both clocks are directly visually comparable
|
|
343
|
+
x_enc = alt.X("Mean Age:Q", scale=alt.Scale(domain=[x_lo, x_hi]),
|
|
344
|
+
title="Mean Age (years)",
|
|
345
|
+
axis=alt.Axis(grid=True, gridColor="#ebebeb"))
|
|
346
|
+
# Only show y-axis title on the leftmost plot to reduce clutter
|
|
347
|
+
y_title = "Prediction Error: Bio − Chron (years)" if i == 0 else None
|
|
348
|
+
y_enc = alt.Y("Prediction Error:Q",
|
|
349
|
+
scale=alt.Scale(domain=[-100, 100]),
|
|
350
|
+
title=y_title,
|
|
351
|
+
axis=alt.Axis(grid=True, gridColor="#ebebeb"))
|
|
352
|
+
|
|
353
|
+
# ── Scatter points ──────────────────────────────────────────────
|
|
354
|
+
pts = (
|
|
355
|
+
alt.Chart(pts_df)
|
|
356
|
+
.mark_point(size=28, filled=True, opacity=0.40)
|
|
357
|
+
.encode(
|
|
358
|
+
x=x_enc, y=y_enc,
|
|
359
|
+
color=alt.value(color),
|
|
360
|
+
tooltip=[
|
|
361
|
+
alt.Tooltip("Mean Age:Q", format=".1f", title="Mean Age"),
|
|
362
|
+
alt.Tooltip("Prediction Error:Q", format="+.1f", title="Error (yrs)"),
|
|
363
|
+
],
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# ── Mean bias line (solid, prominent) ──────────────────────────
|
|
368
|
+
mean_line = (
|
|
369
|
+
alt.Chart(bias_df)
|
|
370
|
+
.mark_rule(size=2.5, opacity=0.92)
|
|
371
|
+
.encode(y=alt.Y("y:Q"), color=alt.value(color))
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# ── Upper LoA (dashed) ──────────────────────────────────────────
|
|
375
|
+
upper_line = (
|
|
376
|
+
alt.Chart(upper_df)
|
|
377
|
+
.mark_rule(size=1.6, opacity=0.70, strokeDash=[6, 3])
|
|
378
|
+
.encode(y=alt.Y("y:Q"), color=alt.value(color))
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# ── Lower LoA (dashed) ──────────────────────────────────────────
|
|
382
|
+
lower_line = (
|
|
383
|
+
alt.Chart(lower_df)
|
|
384
|
+
.mark_rule(size=1.6, opacity=0.70, strokeDash=[6, 3])
|
|
385
|
+
.encode(y=alt.Y("y:Q"), color=alt.value(color))
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# ── Zero reference (light grey) ─────────────────────────────────
|
|
389
|
+
zero_line = (
|
|
390
|
+
alt.Chart(zero_df)
|
|
391
|
+
.mark_rule(size=1.0, opacity=0.30, strokeDash=[2, 2], color="#888888")
|
|
392
|
+
.encode(y=alt.Y("y:Q"))
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
plot = (
|
|
396
|
+
(pts + zero_line + lower_line + upper_line + mean_line)
|
|
397
|
+
.properties(
|
|
398
|
+
width=390, height=360,
|
|
399
|
+
title=alt.TitleParams(
|
|
400
|
+
f"{clock_name} — Bland-Altman",
|
|
401
|
+
fontSize=13, fontWeight="bold", color="#2c3e50",
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
clock_charts.append(plot)
|
|
406
|
+
|
|
407
|
+
interp_rows.append({
|
|
408
|
+
"name": clock_name,
|
|
409
|
+
"color": color,
|
|
410
|
+
"bias": mean_bias,
|
|
411
|
+
"upper": upper_loa,
|
|
412
|
+
"lower": lower_loa,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
# ── hconcat: one Vega embed for both plots ─────────────────────────
|
|
416
|
+
combined = (
|
|
417
|
+
alt.hconcat(*clock_charts)
|
|
418
|
+
.configure_view(strokeWidth=0)
|
|
419
|
+
.configure_axis(labelFontSize=11, titleFontSize=12)
|
|
420
|
+
)
|
|
421
|
+
chart_html = combined.to_html(embed_options={"actions": False})
|
|
422
|
+
|
|
423
|
+
# ── Interpretation block ────────────────────────────────────────────
|
|
424
|
+
interp_html = ""
|
|
425
|
+
for row in interp_rows:
|
|
426
|
+
interp_html += f"""
|
|
427
|
+
<div style="text-align:center;min-width:180px;">
|
|
428
|
+
<span style="display:inline-block;width:12px;height:12px;border-radius:2px;
|
|
429
|
+
background:{row['color']};margin-right:6px;vertical-align:middle;"></span>
|
|
430
|
+
<strong style="color:{row['color']};">{row['name']}</strong><br>
|
|
431
|
+
<span style="font-size:12px;color:#555;line-height:1.8;">
|
|
432
|
+
Mean bias: <strong>{row['bias']:+.2f} yrs</strong><br>
|
|
433
|
+
95% LoA: <strong>{row['lower']:+.2f}</strong> to <strong>{row['upper']:+.2f} yrs</strong>
|
|
434
|
+
</span>
|
|
435
|
+
</div>"""
|
|
436
|
+
|
|
437
|
+
stats_html = _stats_table(summary_df, top_clocks)
|
|
438
|
+
|
|
439
|
+
return f"""
|
|
440
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
441
|
+
padding:0 0 28px 0;">
|
|
442
|
+
<p style="color:#555;font-size:13px;margin:0 0 12px 0;">
|
|
443
|
+
Solid line = mean bias. Dashed lines = 95% limits of agreement (±1.96 SD).
|
|
444
|
+
Grey dotted line = zero error. Points should cluster around zero;
|
|
445
|
+
systematic drift with age indicates heteroscedasticity.
|
|
446
|
+
Independent y-axes: KDM has wider spread than PhenoAge by design.
|
|
447
|
+
</p>
|
|
448
|
+
{chart_html}
|
|
449
|
+
<div style="display:flex;justify-content:center;gap:48px;
|
|
450
|
+
flex-wrap:wrap;margin:14px 0 20px 0;">{interp_html}</div>
|
|
451
|
+
<h3 style="margin:20px 0 8px 0;color:#2c3e50;">Clock Performance Summary</h3>
|
|
452
|
+
{stats_html}
|
|
453
|
+
</div>
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def generate_scatter_heatmap(
|
|
458
|
+
df: pd.DataFrame,
|
|
459
|
+
summary_df: pd.DataFrame,
|
|
460
|
+
results: dict[str, "ClockResult"],
|
|
461
|
+
top_n_clocks: int = 2,
|
|
462
|
+
) -> str:
|
|
463
|
+
"""Generate an Altair-based scatter visualization for biological vs chronological age.
|
|
464
|
+
|
|
465
|
+
Selects the top N clocks by Pearson r and renders an interactive scatter
|
|
466
|
+
with a dashed identity line (y=x) and a styled statistics table.
|
|
467
|
+
|
|
468
|
+
Parameters
|
|
469
|
+
----------
|
|
470
|
+
df : original input DataFrame (must contain 'age' column)
|
|
471
|
+
summary_df : benchmark summary DataFrame from BenchmarkReport.to_dataframe()
|
|
472
|
+
results : dict mapping clock name -> ClockResult
|
|
473
|
+
top_n_clocks : number of clocks to display (2 recommended for clarity)
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
str — HTML fragment containing the Altair chart + stats table
|
|
478
|
+
"""
|
|
479
|
+
import altair as alt
|
|
480
|
+
|
|
481
|
+
# Altair 5+ enforces a 5,000-row limit; disable it so all points render inline.
|
|
482
|
+
alt.data_transformers.disable_max_rows()
|
|
483
|
+
|
|
484
|
+
# Prefer PhenoAge + KDM for meaningful contrast (different algorithm families).
|
|
485
|
+
# Fall back to Pearson r ranking only if those names aren't present.
|
|
486
|
+
_PREFERRED_ORDER = ["PhenoAge", "KDM", "DunedinPACEProxy"]
|
|
487
|
+
available = [c for c in _PREFERRED_ORDER if c in results]
|
|
488
|
+
if len(available) >= top_n_clocks:
|
|
489
|
+
top_clocks = available[:top_n_clocks]
|
|
490
|
+
elif "Pearson r" in summary_df.columns:
|
|
491
|
+
top_clocks = summary_df.nlargest(top_n_clocks, "Pearson r")["Clock"].tolist()
|
|
492
|
+
else:
|
|
493
|
+
top_clocks = list(results.keys())[:top_n_clocks]
|
|
494
|
+
|
|
495
|
+
# Build combined DataFrame — one row per participant per clock
|
|
496
|
+
frames = []
|
|
497
|
+
for clock_name in top_clocks:
|
|
498
|
+
result = results.get(clock_name)
|
|
499
|
+
if result is None:
|
|
500
|
+
continue
|
|
501
|
+
# Align chronological age with the rows the clock actually processed
|
|
502
|
+
if result.original_index is not None:
|
|
503
|
+
age = df.loc[result.original_index, "age"].values
|
|
504
|
+
else:
|
|
505
|
+
age = df["age"].iloc[: result.output_rows].values
|
|
506
|
+
|
|
507
|
+
bio_age = result.biological_ages.values
|
|
508
|
+
frames.append(
|
|
509
|
+
pd.DataFrame(
|
|
510
|
+
{
|
|
511
|
+
"Chronological Age": age.astype(float),
|
|
512
|
+
"Biological Age": bio_age.astype(float),
|
|
513
|
+
"Clock": clock_name,
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not frames:
|
|
519
|
+
return "<p>No clock data available for visualization.</p>"
|
|
520
|
+
|
|
521
|
+
plot_df = pd.concat(frames, ignore_index=True)
|
|
522
|
+
|
|
523
|
+
# Interactive selection — click legend to highlight a clock
|
|
524
|
+
selection = alt.selection_point(fields=["Clock"], bind="legend")
|
|
525
|
+
|
|
526
|
+
scatter = (
|
|
527
|
+
alt.Chart(plot_df)
|
|
528
|
+
.mark_point(size=25, filled=True, stroke=None)
|
|
529
|
+
.encode(
|
|
530
|
+
x=alt.X(
|
|
531
|
+
"Chronological Age:Q",
|
|
532
|
+
scale=alt.Scale(zero=False),
|
|
533
|
+
title="Chronological Age (years)",
|
|
534
|
+
axis=alt.Axis(grid=True, gridColor="#e0e0e0"),
|
|
535
|
+
),
|
|
536
|
+
y=alt.Y(
|
|
537
|
+
"Biological Age:Q",
|
|
538
|
+
scale=alt.Scale(zero=False),
|
|
539
|
+
title="Biological Age (years)",
|
|
540
|
+
axis=alt.Axis(grid=True, gridColor="#e0e0e0"),
|
|
541
|
+
),
|
|
542
|
+
color=alt.Color(
|
|
543
|
+
"Clock:N",
|
|
544
|
+
scale=alt.Scale(
|
|
545
|
+
domain=top_clocks,
|
|
546
|
+
range=_CLOCK_COLORS[:len(top_clocks)],
|
|
547
|
+
),
|
|
548
|
+
title="Clock Algorithm",
|
|
549
|
+
legend=alt.Legend(orient="bottom", direction="horizontal"),
|
|
550
|
+
),
|
|
551
|
+
opacity=alt.condition(selection, alt.value(0.55), alt.value(0.08)),
|
|
552
|
+
tooltip=[
|
|
553
|
+
alt.Tooltip("Chronological Age:Q", format=".1f"),
|
|
554
|
+
alt.Tooltip("Biological Age:Q", format=".1f"),
|
|
555
|
+
"Clock:N",
|
|
556
|
+
],
|
|
557
|
+
)
|
|
558
|
+
.add_params(selection)
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Identity line y = x
|
|
562
|
+
age_range = [
|
|
563
|
+
float(plot_df["Chronological Age"].min()),
|
|
564
|
+
float(plot_df["Chronological Age"].max()),
|
|
565
|
+
]
|
|
566
|
+
identity_df = pd.DataFrame(
|
|
567
|
+
{"Chronological Age": age_range, "Biological Age": age_range}
|
|
568
|
+
)
|
|
569
|
+
identity_line = (
|
|
570
|
+
alt.Chart(identity_df)
|
|
571
|
+
.mark_line(strokeDash=[6, 4], color="#555555", size=2, opacity=0.8)
|
|
572
|
+
.encode(
|
|
573
|
+
x="Chronological Age:Q",
|
|
574
|
+
y="Biological Age:Q",
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
chart = (
|
|
579
|
+
(scatter + identity_line)
|
|
580
|
+
.properties(
|
|
581
|
+
width=520,
|
|
582
|
+
height=460,
|
|
583
|
+
title=alt.TitleParams(
|
|
584
|
+
"Biological Age vs Chronological Age",
|
|
585
|
+
fontSize=15,
|
|
586
|
+
fontWeight="bold",
|
|
587
|
+
color="#2c3e50",
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
.configure_view(strokeWidth=0)
|
|
591
|
+
.configure_axis(labelFontSize=11, titleFontSize=12)
|
|
592
|
+
.interactive()
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
chart_html = chart.to_html(embed_options={"actions": False})
|
|
596
|
+
stats_html = _stats_table(summary_df, top_clocks)
|
|
597
|
+
excluded = [c for c in results if c not in top_clocks]
|
|
598
|
+
note = (
|
|
599
|
+
f'<p style="font-size:12px;color:#888;margin-top:8px;">'
|
|
600
|
+
f"<strong>Note:</strong> {', '.join(excluded)} excluded from scatter "
|
|
601
|
+
f"to reduce visual clutter. Full metrics in the benchmark table above.</p>"
|
|
602
|
+
if excluded
|
|
603
|
+
else ""
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return f"""
|
|
607
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
608
|
+
padding:0 0 24px 0;">
|
|
609
|
+
<p style="color:#555;font-size:13px;margin:0 0 12px 0;">
|
|
610
|
+
Top {len(top_clocks)} clocks by Pearson r shown. Click a clock name in the
|
|
611
|
+
legend to highlight it. Dashed line = perfect prediction (y = x).
|
|
612
|
+
</p>
|
|
613
|
+
{chart_html}
|
|
614
|
+
<h3 style="margin:24px 0 8px 0;color:#2c3e50;">Clock Performance Summary</h3>
|
|
615
|
+
{stats_html}
|
|
616
|
+
{note}
|
|
617
|
+
</div>
|
|
618
|
+
"""
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _stats_table(summary_df: pd.DataFrame, clock_names: list[str]) -> str:
|
|
622
|
+
"""Render a styled HTML stats table for the selected clocks."""
|
|
623
|
+
filtered = summary_df[summary_df["Clock"].isin(clock_names)].copy()
|
|
624
|
+
|
|
625
|
+
# Columns to show and how to format them
|
|
626
|
+
col_map = {
|
|
627
|
+
"Clock": ("Clock", lambda v: str(v)),
|
|
628
|
+
"Pearson r": ("Pearson r", lambda v: f"{v:.3f}"),
|
|
629
|
+
"Spearman r": ("Spearman r", lambda v: f"{v:.3f}"),
|
|
630
|
+
"Mort HR (per SD accel)": ("Mort HR (per SD accel)", lambda v: f"{v:.2f}"),
|
|
631
|
+
"Mort p-value": ("Mort p-value", lambda v: "<0.0001" if v < 0.0001 else f"{v:.4f}"),
|
|
632
|
+
"CV": ("CV", lambda v: f"{v:.3f}"),
|
|
633
|
+
}
|
|
634
|
+
present = {k: v for k, v in col_map.items() if k in filtered.columns}
|
|
635
|
+
|
|
636
|
+
header_cells = "".join(
|
|
637
|
+
f'<th style="padding:9px 14px;text-align:{"left" if k=="Clock" else "center"};'
|
|
638
|
+
f'font-weight:600;color:#fff;">{label}</th>'
|
|
639
|
+
for k, (label, _) in present.items()
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
rows_html = ""
|
|
643
|
+
for i, (_, row) in enumerate(filtered.iterrows()):
|
|
644
|
+
bg = "#f9f9f9" if i % 2 == 0 else "#ffffff"
|
|
645
|
+
cells = "".join(
|
|
646
|
+
f'<td style="padding:9px 14px;text-align:{"left" if k=="Clock" else "center"};">'
|
|
647
|
+
f"{fmt(row[k])}</td>"
|
|
648
|
+
for k, (_, fmt) in present.items()
|
|
649
|
+
)
|
|
650
|
+
rows_html += f'<tr style="background:{bg};border-bottom:1px solid #e8e8e8;">{cells}</tr>'
|
|
651
|
+
|
|
652
|
+
return f"""
|
|
653
|
+
<table style="border-collapse:collapse;width:100%;max-width:860px;font-size:13px;
|
|
654
|
+
box-shadow:0 1px 3px rgba(0,0,0,0.08);border-radius:4px;overflow:hidden;">
|
|
655
|
+
<thead style="background:#2c3e50;">
|
|
656
|
+
<tr>{header_cells}</tr>
|
|
657
|
+
</thead>
|
|
658
|
+
<tbody>{rows_html}</tbody>
|
|
659
|
+
</table>"""
|
|
@@ -204,31 +204,48 @@ def to_html(
|
|
|
204
204
|
import plotly.graph_objects as go
|
|
205
205
|
from plotly.subplots import make_subplots
|
|
206
206
|
import plotly.express as px
|
|
207
|
+
from .altair_plots import generate_bland_altman_plots, _stats_table
|
|
207
208
|
|
|
209
|
+
# --- Benchmark table: clean HTML (no Plotly widget, no duplicate title) ---
|
|
210
|
+
summary_df = report.to_dataframe()
|
|
211
|
+
html_table = _stats_table(summary_df, list(summary_df["Clock"]))
|
|
212
|
+
|
|
213
|
+
# --- Scatter: Altair (preferred) with Plotly fallback ---
|
|
214
|
+
altair_html_section: str | None = None
|
|
215
|
+
try:
|
|
216
|
+
altair_html_section = generate_bland_altman_plots(
|
|
217
|
+
df=df,
|
|
218
|
+
summary_df=summary_df,
|
|
219
|
+
results=results,
|
|
220
|
+
top_n_clocks=2,
|
|
221
|
+
)
|
|
222
|
+
except Exception as _altair_err:
|
|
223
|
+
import warnings
|
|
224
|
+
warnings.warn(
|
|
225
|
+
f"Altair scatter failed ({_altair_err}); falling back to Plotly.",
|
|
226
|
+
stacklevel=2,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Plotly fallback scatter (used if Altair is unavailable or errors)
|
|
208
230
|
n = len(results)
|
|
209
|
-
|
|
231
|
+
colors = px.colors.qualitative.Plotly
|
|
210
232
|
fig_scatter = make_subplots(
|
|
211
233
|
rows=1, cols=n,
|
|
212
|
-
subplot_titles=
|
|
234
|
+
subplot_titles=list(results.keys()),
|
|
213
235
|
shared_yaxes=False,
|
|
214
236
|
)
|
|
215
|
-
colors = px.colors.qualitative.Plotly
|
|
216
|
-
|
|
217
237
|
for col, (name, result) in enumerate(results.items(), start=1):
|
|
218
238
|
if result.original_index is not None:
|
|
219
239
|
age = df.loc[result.original_index, "age"].values
|
|
220
240
|
else:
|
|
221
241
|
age = df["age"].iloc[: result.output_rows].values
|
|
222
242
|
bio_age = result.biological_ages.values
|
|
223
|
-
|
|
224
243
|
br = next((r for r in report.results if r.clock_name == name), None)
|
|
225
244
|
r_val = br.pearson_r if br else float("nan")
|
|
226
|
-
|
|
227
245
|
fig_scatter.add_trace(
|
|
228
246
|
go.Scatter(
|
|
229
|
-
x=age, y=bio_age,
|
|
230
|
-
|
|
231
|
-
marker=dict(size=4, color=colors[col - 1], opacity=0.4),
|
|
247
|
+
x=age, y=bio_age, mode="markers",
|
|
248
|
+
marker=dict(size=6, color=colors[col - 1], opacity=0.65),
|
|
232
249
|
name=f"{name} (r={r_val:.3f})",
|
|
233
250
|
),
|
|
234
251
|
row=1, col=col,
|
|
@@ -241,47 +258,31 @@ def to_html(
|
|
|
241
258
|
showlegend=False),
|
|
242
259
|
row=1, col=col,
|
|
243
260
|
)
|
|
244
|
-
|
|
245
261
|
fig_scatter.update_layout(
|
|
246
262
|
title="Biological Age vs Chronological Age",
|
|
247
|
-
height=
|
|
248
|
-
template="plotly_white",
|
|
263
|
+
height=520, template="plotly_white",
|
|
249
264
|
)
|
|
250
265
|
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
fig_table = go.Figure(
|
|
260
|
-
data=[go.Table(
|
|
261
|
-
header=dict(
|
|
262
|
-
values=list(summary_df.columns),
|
|
263
|
-
fill_color="#2c3e50",
|
|
264
|
-
font=dict(color="white", size=12),
|
|
265
|
-
align="left",
|
|
266
|
-
),
|
|
267
|
-
cells=dict(
|
|
268
|
-
values=[_fmt(c) for c in summary_df.columns],
|
|
269
|
-
fill_color="lavender",
|
|
270
|
-
align="left",
|
|
271
|
-
),
|
|
272
|
-
)]
|
|
266
|
+
# Combine into single HTML
|
|
267
|
+
html_scatter_fallback = fig_scatter.to_html(full_html=False, include_plotlyjs=False)
|
|
268
|
+
|
|
269
|
+
# Only include Plotly CDN if Altair failed and we fell back to Plotly scatter
|
|
270
|
+
plotly_cdn = (
|
|
271
|
+
'<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>'
|
|
272
|
+
if altair_html_section is None else ""
|
|
273
273
|
)
|
|
274
|
-
fig_table.update_layout(title="Benchmark Summary", height=200)
|
|
275
274
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
275
|
+
scatter_section = (
|
|
276
|
+
f'<h2>Biological Age vs Chronological Age</h2>{altair_html_section}'
|
|
277
|
+
if altair_html_section is not None
|
|
278
|
+
else f'<h2>Biological Age vs Chronological Age</h2>{html_scatter_fallback}'
|
|
279
|
+
)
|
|
279
280
|
|
|
280
281
|
html = f"""<!DOCTYPE html>
|
|
281
282
|
<html>
|
|
282
283
|
<head>
|
|
283
284
|
<title>AgingClockBench Report</title>
|
|
284
|
-
|
|
285
|
+
{plotly_cdn}
|
|
285
286
|
<style>
|
|
286
287
|
body {{ font-family: Arial, sans-serif; max-width: 1200px; margin: auto; padding: 20px; }}
|
|
287
288
|
h1 {{ color: #2c3e50; }}
|
|
@@ -292,8 +293,7 @@ def to_html(
|
|
|
292
293
|
<h1>AgingClockBench Report</h1>
|
|
293
294
|
<h2>Benchmark Summary</h2>
|
|
294
295
|
{html_table}
|
|
295
|
-
|
|
296
|
-
{html_scatter}
|
|
296
|
+
{scatter_section}
|
|
297
297
|
</body>
|
|
298
298
|
</html>"""
|
|
299
299
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/nhanes_sample.parquet
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|