agingclockbench 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """AgingClockBench: Benchmark biological aging clocks on your data."""
2
+
3
+ from agingclockbench.clocks.phenoage import PhenoAge
4
+ from agingclockbench.clocks.kdm import KDM
5
+ from agingclockbench.clocks.dunedinpace import DunedinPACEProxy
6
+ from agingclockbench.benchmarks.suite import BenchmarkSuite
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["PhenoAge", "KDM", "DunedinPACEProxy", "BenchmarkSuite"]
@@ -0,0 +1,3 @@
1
+ from agingclockbench.benchmarks.suite import BenchmarkSuite, BenchmarkResult, BenchmarkReport
2
+
3
+ __all__ = ["BenchmarkSuite", "BenchmarkResult", "BenchmarkReport"]
@@ -0,0 +1,22 @@
1
+ """Individual metric functions used by BenchmarkSuite."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from scipy import stats
6
+
7
+
8
+ def pearson_correlation(x: pd.Series, y: pd.Series) -> tuple[float, float]:
9
+ """Return (r, p-value) Pearson correlation between x and y."""
10
+ r, p = stats.pearsonr(x, y)
11
+ return float(r), float(p)
12
+
13
+
14
+ def spearman_correlation(x: pd.Series, y: pd.Series) -> float:
15
+ """Return Spearman rho between x and y."""
16
+ return float(stats.spearmanr(x, y).statistic)
17
+
18
+
19
+ def coefficient_of_variation(series: pd.Series) -> float:
20
+ """Return coefficient of variation (SD / mean)."""
21
+ mean = series.mean()
22
+ return float(series.std() / mean) if mean != 0 else float("nan")
@@ -0,0 +1,302 @@
1
+ """Visualization functions for BenchmarkReport.
2
+
3
+ All functions return matplotlib/plotly Figure objects so callers can
4
+ save, display, or embed them as needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ if TYPE_CHECKING:
15
+ from agingclockbench.benchmarks.suite import BenchmarkReport
16
+ from agingclockbench.clocks.base import ClockResult
17
+
18
+
19
+ def plot_comparison(
20
+ report: "BenchmarkReport",
21
+ df: pd.DataFrame,
22
+ results: dict[str, "ClockResult"],
23
+ ):
24
+ """Scatter plot of biological age vs chronological age for each clock.
25
+
26
+ Parameters
27
+ ----------
28
+ report : BenchmarkReport from BenchmarkSuite.run()
29
+ df : original input DataFrame (must contain 'age')
30
+ results : dict mapping clock name -> ClockResult
31
+
32
+ Returns
33
+ -------
34
+ matplotlib.figure.Figure
35
+ """
36
+ import matplotlib.pyplot as plt
37
+ import seaborn as sns
38
+
39
+ n = len(results)
40
+ fig, axes = plt.subplots(1, n, figsize=(5 * n, 5), squeeze=False)
41
+ palette = sns.color_palette("husl", n)
42
+
43
+ for ax, (name, result), color in zip(axes[0], results.items(), palette):
44
+ if result.original_index is not None:
45
+ age = df.loc[result.original_index, "age"].values
46
+ else:
47
+ age = df["age"].iloc[: result.output_rows].values
48
+
49
+ bio_age = result.biological_ages.values
50
+
51
+ # Scatter with alpha for density
52
+ ax.scatter(age, bio_age, alpha=0.3, s=8, color=color)
53
+
54
+ # Identity line (biological age = chronological age)
55
+ lo, hi = min(age.min(), bio_age.min()), max(age.max(), bio_age.max())
56
+ ax.plot([lo, hi], [lo, hi], "k--", lw=1, label="y = x")
57
+
58
+ # Pearson r from benchmark results
59
+ br = next((r for r in report.results if r.clock_name == name), None)
60
+ r_str = f"r = {br.pearson_r:.3f}" if br and not np.isnan(br.pearson_r) else ""
61
+ ax.set_title(f"{name}\n{r_str}", fontsize=12)
62
+ ax.set_xlabel("Chronological Age (years)")
63
+ ax.set_ylabel("Biological Age (years)")
64
+
65
+ fig.suptitle("Biological Age vs Chronological Age", fontsize=14, y=1.02)
66
+ plt.tight_layout()
67
+ return fig
68
+
69
+
70
+ def plot_km_survival(
71
+ df: pd.DataFrame,
72
+ results: dict[str, "ClockResult"],
73
+ mortality_col: str = "mortstat",
74
+ followup_col: str = "permth_exm",
75
+ n_quartiles: int = 4,
76
+ ):
77
+ """Kaplan-Meier survival curves stratified by age-acceleration quartile.
78
+
79
+ Parameters
80
+ ----------
81
+ df : DataFrame with mortality columns.
82
+ results : dict mapping clock name -> ClockResult.
83
+ mortality_col : event indicator column (1=event, 0=censored).
84
+ followup_col : time-to-event/censoring column (months).
85
+ n_quartiles : number of strata (default 4).
86
+
87
+ Returns
88
+ -------
89
+ matplotlib.figure.Figure
90
+ """
91
+ import matplotlib.pyplot as plt
92
+ from lifelines import KaplanMeierFitter
93
+ import seaborn as sns
94
+
95
+ n = len(results)
96
+ fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), squeeze=False)
97
+ palette = sns.color_palette("RdYlGn_r", n_quartiles)
98
+
99
+ for ax, (name, result) in zip(axes[0], results.items()):
100
+ if result.original_index is not None:
101
+ aligned = df.loc[result.original_index].reset_index(drop=True)
102
+ else:
103
+ aligned = df.iloc[: result.output_rows].reset_index(drop=True)
104
+
105
+ if mortality_col not in aligned.columns or followup_col not in aligned.columns:
106
+ ax.text(0.5, 0.5, "No mortality data", ha="center", va="center",
107
+ transform=ax.transAxes)
108
+ ax.set_title(name)
109
+ continue
110
+
111
+ analysis = pd.DataFrame({
112
+ "accel": result.accel.values,
113
+ "event": aligned[mortality_col].values,
114
+ "time": aligned[followup_col].values,
115
+ }).dropna()
116
+
117
+ quartile_labels = [f"Q{i+1}" for i in range(n_quartiles)]
118
+ analysis["quartile"] = pd.qcut(analysis["accel"], n_quartiles,
119
+ labels=quartile_labels)
120
+
121
+ kmf = KaplanMeierFitter()
122
+ for label, color in zip(quartile_labels, palette):
123
+ mask = analysis["quartile"] == label
124
+ kmf.fit(
125
+ analysis.loc[mask, "time"] / 12, # months → years
126
+ analysis.loc[mask, "event"],
127
+ label=label,
128
+ )
129
+ kmf.plot_survival_function(ax=ax, color=color, ci_show=False)
130
+
131
+ ax.set_title(f"{name}\nKaplan-Meier by Accel Quartile", fontsize=11)
132
+ ax.set_xlabel("Follow-up (years)")
133
+ ax.set_ylabel("Survival Probability")
134
+ ax.legend(title="Accel\nQuartile", fontsize=8)
135
+ ax.set_ylim(0, 1)
136
+
137
+ fig.suptitle("Survival by Biological Age Acceleration Quartile", fontsize=14, y=1.02)
138
+ plt.tight_layout()
139
+ return fig
140
+
141
+
142
+ def plot_correlation_heatmap(results: dict[str, "ClockResult"]):
143
+ """Heatmap of Pearson correlations between clock accelerations.
144
+
145
+ Parameters
146
+ ----------
147
+ results : dict mapping clock name -> ClockResult.
148
+
149
+ Returns
150
+ -------
151
+ matplotlib.figure.Figure
152
+ """
153
+ import matplotlib.pyplot as plt
154
+ import seaborn as sns
155
+
156
+ names = list(results.keys())
157
+ n = len(names)
158
+ corr = np.eye(n)
159
+
160
+ for i, n1 in enumerate(names):
161
+ for j, n2 in enumerate(names):
162
+ if i != j:
163
+ a1 = results[n1].accel
164
+ a2 = results[n2].accel
165
+ min_len = min(len(a1), len(a2))
166
+ if min_len > 2:
167
+ corr[i, j] = a1.iloc[:min_len].corr(a2.iloc[:min_len])
168
+
169
+ corr_df = pd.DataFrame(corr, index=names, columns=names)
170
+ fig, ax = plt.subplots(figsize=(max(4, n * 1.5), max(3, n * 1.5)))
171
+ sns.heatmap(
172
+ corr_df,
173
+ annot=True,
174
+ fmt=".3f",
175
+ cmap="coolwarm",
176
+ vmin=-1,
177
+ vmax=1,
178
+ ax=ax,
179
+ square=True,
180
+ cbar_kws={"shrink": 0.8},
181
+ )
182
+ ax.set_title("Inter-Clock Acceleration Correlations (Pearson r)", fontsize=12)
183
+ plt.tight_layout()
184
+ return fig
185
+
186
+
187
+ def to_html(
188
+ report: "BenchmarkReport",
189
+ df: pd.DataFrame,
190
+ results: dict[str, "ClockResult"],
191
+ filename: str,
192
+ mortality_col: str = "mortstat",
193
+ followup_col: str = "permth_exm",
194
+ ) -> None:
195
+ """Export an interactive Plotly HTML benchmark report.
196
+
197
+ Parameters
198
+ ----------
199
+ report : BenchmarkReport from BenchmarkSuite.run()
200
+ df : original input DataFrame
201
+ results : dict mapping clock name -> ClockResult
202
+ filename : output .html path
203
+ """
204
+ import plotly.graph_objects as go
205
+ from plotly.subplots import make_subplots
206
+ import plotly.express as px
207
+
208
+ n = len(results)
209
+ # --- Scatter subplots ---
210
+ fig_scatter = make_subplots(
211
+ rows=1, cols=n,
212
+ subplot_titles=[f"{name}" for name in results],
213
+ shared_yaxes=False,
214
+ )
215
+ colors = px.colors.qualitative.Plotly
216
+
217
+ for col, (name, result) in enumerate(results.items(), start=1):
218
+ if result.original_index is not None:
219
+ age = df.loc[result.original_index, "age"].values
220
+ else:
221
+ age = df["age"].iloc[: result.output_rows].values
222
+ bio_age = result.biological_ages.values
223
+
224
+ br = next((r for r in report.results if r.clock_name == name), None)
225
+ r_val = br.pearson_r if br else float("nan")
226
+
227
+ fig_scatter.add_trace(
228
+ go.Scatter(
229
+ x=age, y=bio_age,
230
+ mode="markers",
231
+ marker=dict(size=4, color=colors[col - 1], opacity=0.4),
232
+ name=f"{name} (r={r_val:.3f})",
233
+ ),
234
+ row=1, col=col,
235
+ )
236
+ lo = min(float(age.min()), float(bio_age.min()))
237
+ hi = max(float(age.max()), float(bio_age.max()))
238
+ fig_scatter.add_trace(
239
+ go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
240
+ line=dict(color="black", dash="dash", width=1),
241
+ showlegend=False),
242
+ row=1, col=col,
243
+ )
244
+
245
+ fig_scatter.update_layout(
246
+ title="Biological Age vs Chronological Age",
247
+ height=450,
248
+ template="plotly_white",
249
+ )
250
+
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
+ )]
273
+ )
274
+ fig_table.update_layout(title="Benchmark Summary", height=200)
275
+
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)
279
+
280
+ html = f"""<!DOCTYPE html>
281
+ <html>
282
+ <head>
283
+ <title>AgingClockBench Report</title>
284
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
285
+ <style>
286
+ body {{ font-family: Arial, sans-serif; max-width: 1200px; margin: auto; padding: 20px; }}
287
+ h1 {{ color: #2c3e50; }}
288
+ h2 {{ color: #34495e; border-bottom: 1px solid #bdc3c7; padding-bottom: 6px; }}
289
+ </style>
290
+ </head>
291
+ <body>
292
+ <h1>AgingClockBench Report</h1>
293
+ <h2>Benchmark Summary</h2>
294
+ {html_table}
295
+ <h2>Biological Age vs Chronological Age</h2>
296
+ {html_scatter}
297
+ </body>
298
+ </html>"""
299
+
300
+ with open(filename, "w", encoding="utf-8") as f:
301
+ f.write(html)
302
+ print(f"Report saved to {filename}")
@@ -0,0 +1,262 @@
1
+ """BenchmarkSuite — runs validation metrics across multiple aging clocks."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy import stats
8
+
9
+ from agingclockbench.clocks.base import ClockResult
10
+
11
+
12
+ @dataclass
13
+ class BenchmarkResult:
14
+ """Validation metrics for a single clock."""
15
+
16
+ clock_name: str
17
+ pearson_r: float = float("nan")
18
+ spearman_r: float = float("nan")
19
+ pearson_pvalue: float = float("nan")
20
+ mortality_hr: float = float("nan")
21
+ mortality_hr_ci_lower: float = float("nan")
22
+ mortality_hr_ci_upper: float = float("nan")
23
+ mortality_pvalue: float = float("nan")
24
+ cox_nobs: int = 0
25
+ cv: float = float("nan")
26
+ clock_agreement_with_others: dict = field(default_factory=dict)
27
+
28
+
29
+ class BenchmarkSuite:
30
+ """Run a standardised validation benchmark on one or more aging clocks.
31
+
32
+ Parameters
33
+ ----------
34
+ mortality_col : str
35
+ Column name for vital status (1 = dead, 0 = censored).
36
+ followup_col : str
37
+ Column name for follow-up time. Units must be consistent — the HR
38
+ interpretation assumes months if using NHANES permth_exm.
39
+
40
+ Examples
41
+ --------
42
+ >>> suite = BenchmarkSuite(mortality_col="mortstat", followup_col="permth_exm")
43
+ >>> report = suite.run(df, results={"PhenoAge": phenoage_result})
44
+ >>> print(report.to_dataframe())
45
+ """
46
+
47
+ def __init__(self, mortality_col: str = "mortstat", followup_col: str = "permth_exm") -> None:
48
+ self.mortality_col = mortality_col
49
+ self.followup_col = followup_col
50
+
51
+ def run(
52
+ self,
53
+ df: pd.DataFrame,
54
+ results: dict[str, ClockResult],
55
+ ) -> "BenchmarkReport":
56
+ """Compute benchmark metrics for each clock result.
57
+
58
+ Uses ``ClockResult.original_index`` to align clock outputs with
59
+ the correct rows in ``df`` (handles missing-data row drops).
60
+
61
+ Parameters
62
+ ----------
63
+ df : Original input DataFrame.
64
+ results : Mapping of clock name → ClockResult.
65
+
66
+ Returns
67
+ -------
68
+ BenchmarkReport
69
+ """
70
+ benchmark_results: list[BenchmarkResult] = []
71
+ accel_series: dict[str, pd.Series] = {}
72
+
73
+ for name, result in results.items():
74
+ br = BenchmarkResult(clock_name=name)
75
+
76
+ # Align df rows to the rows the clock actually processed
77
+ if result.original_index is not None:
78
+ aligned_df = df.loc[result.original_index].reset_index(drop=True)
79
+ else:
80
+ aligned_df = df.iloc[: result.output_rows].reset_index(drop=True)
81
+
82
+ age = aligned_df["age"]
83
+
84
+ # Pearson / Spearman correlation with chronological age
85
+ if age.nunique() > 1:
86
+ r, p = stats.pearsonr(age, result.biological_ages)
87
+ br.pearson_r = round(float(r), 4)
88
+ br.pearson_pvalue = round(float(p), 6)
89
+ spr = stats.spearmanr(age, result.biological_ages).statistic
90
+ br.spearman_r = round(float(spr), 4)
91
+
92
+ # Coefficient of variation
93
+ mean_ba = result.biological_ages.mean()
94
+ std_ba = result.biological_ages.std()
95
+ br.cv = round(float(std_ba / mean_ba), 4) if mean_ba != 0 else float("nan")
96
+
97
+ # Cox PH mortality prediction
98
+ if self.mortality_col in aligned_df.columns and self.followup_col in aligned_df.columns:
99
+ br = self._run_cox(aligned_df, result, br)
100
+
101
+ benchmark_results.append(br)
102
+ accel_series[name] = result.accel
103
+
104
+ # Inter-clock agreement (Pearson r of accelerations)
105
+ for br in benchmark_results:
106
+ others = {k: v for k, v in accel_series.items() if k != br.clock_name}
107
+ for other_name, other_accel in others.items():
108
+ min_len = min(len(accel_series[br.clock_name]), len(other_accel))
109
+ if min_len > 2 and accel_series[br.clock_name].iloc[:min_len].nunique() > 1:
110
+ r, _ = stats.pearsonr(
111
+ accel_series[br.clock_name].iloc[:min_len],
112
+ other_accel.iloc[:min_len],
113
+ )
114
+ br.clock_agreement_with_others[other_name] = round(float(r), 4)
115
+
116
+ return BenchmarkReport(
117
+ results=benchmark_results,
118
+ df=df,
119
+ clock_results=results,
120
+ mortality_col=self.mortality_col,
121
+ followup_col=self.followup_col,
122
+ )
123
+
124
+ def _run_cox(
125
+ self,
126
+ aligned_df: pd.DataFrame,
127
+ result: ClockResult,
128
+ br: BenchmarkResult,
129
+ ) -> BenchmarkResult:
130
+ """Fit a Cox PH model: mortality ~ clock_acceleration_sd + age."""
131
+ try:
132
+ from lifelines import CoxPHFitter
133
+
134
+ analysis_df = aligned_df[[self.mortality_col, self.followup_col, "age"]].copy()
135
+ analysis_df["clock_acceleration"] = result.accel.values
136
+
137
+ # Standardise acceleration to per-SD hazard ratio
138
+ sd = analysis_df["clock_acceleration"].std()
139
+ if sd > 0:
140
+ analysis_df["clock_acceleration"] /= sd
141
+
142
+ analysis_df = analysis_df.dropna()
143
+ if len(analysis_df) < 10 or analysis_df[self.mortality_col].sum() == 0:
144
+ return br
145
+
146
+ cph = CoxPHFitter()
147
+ cph.fit(
148
+ analysis_df,
149
+ duration_col=self.followup_col,
150
+ event_col=self.mortality_col,
151
+ formula="clock_acceleration + age",
152
+ )
153
+ summary = cph.summary
154
+ row = summary.loc["clock_acceleration"]
155
+ br.mortality_hr = round(float(np.exp(row["coef"])), 4)
156
+ br.mortality_hr_ci_lower = round(float(np.exp(row["coef lower 95%"])), 4)
157
+ br.mortality_hr_ci_upper = round(float(np.exp(row["coef upper 95%"])), 4)
158
+ br.mortality_pvalue = round(float(row["p"]), 6)
159
+ br.cox_nobs = int(analysis_df[self.mortality_col].sum())
160
+ except Exception:
161
+ pass
162
+ return br
163
+
164
+
165
+ class BenchmarkReport:
166
+ """Container for all benchmark results with display and export methods.
167
+
168
+ Attributes
169
+ ----------
170
+ results : list[BenchmarkResult]
171
+ _df : the original input DataFrame (set by BenchmarkSuite.run)
172
+ _clock_results : dict mapping clock name -> ClockResult
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ results: list[BenchmarkResult],
178
+ df: pd.DataFrame | None = None,
179
+ clock_results: dict | None = None,
180
+ mortality_col: str = "mortstat",
181
+ followup_col: str = "permth_exm",
182
+ ) -> None:
183
+ self.results = results
184
+ self._df = df
185
+ self._clock_results = clock_results or {}
186
+ self._mortality_col = mortality_col
187
+ self._followup_col = followup_col
188
+
189
+ def to_dataframe(self) -> pd.DataFrame:
190
+ """Return a summary DataFrame — one row per clock."""
191
+ rows = []
192
+ for r in self.results:
193
+ rows.append({
194
+ "Clock": r.clock_name,
195
+ "Pearson r": r.pearson_r,
196
+ "Spearman r": r.spearman_r,
197
+ "Mort HR (per SD accel)": r.mortality_hr,
198
+ "HR 95% CI lower": r.mortality_hr_ci_lower,
199
+ "HR 95% CI upper": r.mortality_hr_ci_upper,
200
+ "Mort p-value": r.mortality_pvalue,
201
+ "Cox N (events)": r.cox_nobs,
202
+ "CV": r.cv,
203
+ })
204
+ return pd.DataFrame(rows)
205
+
206
+ def plot_comparison(self, df: pd.DataFrame | None = None,
207
+ results: dict | None = None):
208
+ """Scatter plot of biological age vs chronological age per clock.
209
+
210
+ Returns matplotlib Figure. Pass ``df`` and ``results`` only if you
211
+ did not run via BenchmarkSuite.run().
212
+ """
213
+ from agingclockbench.benchmarks.plots import plot_comparison
214
+ return plot_comparison(
215
+ self,
216
+ df if df is not None else self._df,
217
+ results if results is not None else self._clock_results,
218
+ )
219
+
220
+ def plot_km_survival(self, df: pd.DataFrame | None = None,
221
+ results: dict | None = None,
222
+ n_quartiles: int = 4):
223
+ """Kaplan-Meier survival by age-acceleration quartile.
224
+
225
+ Returns matplotlib Figure.
226
+ """
227
+ from agingclockbench.benchmarks.plots import plot_km_survival
228
+ return plot_km_survival(
229
+ df if df is not None else self._df,
230
+ results if results is not None else self._clock_results,
231
+ mortality_col=self._mortality_col,
232
+ followup_col=self._followup_col,
233
+ n_quartiles=n_quartiles,
234
+ )
235
+
236
+ def plot_correlation_heatmap(self, results: dict | None = None):
237
+ """Heatmap of Pearson correlations between clock accelerations.
238
+
239
+ Returns matplotlib Figure.
240
+ """
241
+ from agingclockbench.benchmarks.plots import plot_correlation_heatmap
242
+ return plot_correlation_heatmap(
243
+ results if results is not None else self._clock_results
244
+ )
245
+
246
+ def to_html(self, filename: str, df: pd.DataFrame | None = None,
247
+ results: dict | None = None) -> None:
248
+ """Export an interactive Plotly HTML benchmark report.
249
+
250
+ Parameters
251
+ ----------
252
+ filename : output path (e.g. 'report.html')
253
+ """
254
+ from agingclockbench.benchmarks.plots import to_html
255
+ to_html(
256
+ self,
257
+ df if df is not None else self._df,
258
+ results if results is not None else self._clock_results,
259
+ filename,
260
+ mortality_col=self._mortality_col,
261
+ followup_col=self._followup_col,
262
+ )