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.

Files changed (22) hide show
  1. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/PKG-INFO +6 -5
  2. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/README.md +2 -2
  3. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/pyproject.toml +4 -3
  4. agingclockbench-0.2.0/src/agingclockbench/benchmarks/altair_plots.py +659 -0
  5. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/plots.py +41 -41
  6. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/LICENSE +0 -0
  7. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/__init__.py +0 -0
  8. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/__init__.py +0 -0
  9. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/metrics.py +0 -0
  10. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/benchmarks/suite.py +0 -0
  11. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/cli.py +0 -0
  12. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/__init__.py +0 -0
  13. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/base.py +0 -0
  14. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/dunedinpace.py +0 -0
  15. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/kdm.py +0 -0
  16. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/clocks/phenoage.py +0 -0
  17. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/config.py +0 -0
  18. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/__init__.py +0 -0
  19. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/loaders.py +0 -0
  20. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/datasets/nhanes_sample.parquet +0 -0
  21. {agingclockbench-0.1.0 → agingclockbench-0.2.0}/src/agingclockbench/utils/__init__.py +0 -0
  22. {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.1.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.github.io/aging_clock_bench/
32
- Project-URL: Homepage, https://aadityageddam-ux.github.io/aging_clock_bench/
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
  [![PyPI version](https://badge.fury.io/py/agingclockbench.svg)](https://badge.fury.io/py/agingclockbench)
39
40
  [![Tests](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml/badge.svg)](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml)
40
41
  [![Coverage](https://img.shields.io/badge/coverage-89%25-brightgreen)](https://github.com/aadityageddam-ux/aging_clock_bench)
41
- [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://aadityageddam-ux.github.io/aging_clock_bench/)
42
+ [![Docs](https://img.shields.io/badge/docs-README-blue)](https://github.com/aadityageddam-ux/aging_clock_bench#quick-start)
42
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
43
44
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](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
- 📖 **[Full Documentation](https://aadityageddam-ux.github.io/aging_clock_bench/)**
50
+ 📖 **[Documentation & Examples](#quick-start)**
50
51
 
51
52
  ---
52
53
 
@@ -3,7 +3,7 @@
3
3
  [![PyPI version](https://badge.fury.io/py/agingclockbench.svg)](https://badge.fury.io/py/agingclockbench)
4
4
  [![Tests](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml/badge.svg)](https://github.com/aadityageddam-ux/aging_clock_bench/actions/workflows/test.yml)
5
5
  [![Coverage](https://img.shields.io/badge/coverage-89%25-brightgreen)](https://github.com/aadityageddam-ux/aging_clock_bench)
6
- [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://aadityageddam-ux.github.io/aging_clock_bench/)
6
+ [![Docs](https://img.shields.io/badge/docs-README-blue)](https://github.com/aadityageddam-ux/aging_clock_bench#quick-start)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](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
- 📖 **[Full Documentation](https://aadityageddam-ux.github.io/aging_clock_bench/)**
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.1.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.github.io/aging_clock_bench/"
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.github.io/aging_clock_bench/"
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&apos;s predictions.
253
+ A <em>narrower</em> ellipse means more consistent biological age estimates.
254
+ Dashed black line = perfect prediction (y&nbsp;=&nbsp;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 (&plusmn;1.96&nbsp;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&nbsp;=&nbsp;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
- # --- Scatter subplots ---
231
+ colors = px.colors.qualitative.Plotly
210
232
  fig_scatter = make_subplots(
211
233
  rows=1, cols=n,
212
- subplot_titles=[f"{name}" for name in results],
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
- mode="markers",
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=450,
248
- template="plotly_white",
263
+ height=520, template="plotly_white",
249
264
  )
250
265
 
251
- # --- Benchmark table ---
252
- summary_df = report.to_dataframe()
253
- def _fmt(col):
254
- s = summary_df[col]
255
- if pd.api.types.is_numeric_dtype(s):
256
- return s.round(4).astype(str).tolist()
257
- return s.astype(str).tolist()
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
- # Combine into single HTML
277
- html_scatter = fig_scatter.to_html(full_html=False, include_plotlyjs=False)
278
- html_table = fig_table.to_html(full_html=False, include_plotlyjs=False)
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
- <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
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
- <h2>Biological Age vs Chronological Age</h2>
296
- {html_scatter}
296
+ {scatter_section}
297
297
  </body>
298
298
  </html>"""
299
299
 
File without changes