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.
- agingclockbench/__init__.py +9 -0
- agingclockbench/benchmarks/__init__.py +3 -0
- agingclockbench/benchmarks/metrics.py +22 -0
- agingclockbench/benchmarks/plots.py +302 -0
- agingclockbench/benchmarks/suite.py +262 -0
- agingclockbench/cli.py +153 -0
- agingclockbench/clocks/__init__.py +6 -0
- agingclockbench/clocks/base.py +49 -0
- agingclockbench/clocks/dunedinpace.py +137 -0
- agingclockbench/clocks/kdm.py +175 -0
- agingclockbench/clocks/phenoage.py +156 -0
- agingclockbench/config.py +4 -0
- agingclockbench/datasets/__init__.py +3 -0
- agingclockbench/datasets/loaders.py +54 -0
- agingclockbench/datasets/nhanes_sample.parquet +0 -0
- agingclockbench/utils/__init__.py +3 -0
- agingclockbench/utils/validation.py +10 -0
- agingclockbench-0.1.0.dist-info/METADATA +183 -0
- agingclockbench-0.1.0.dist-info/RECORD +22 -0
- agingclockbench-0.1.0.dist-info/WHEEL +4 -0
- agingclockbench-0.1.0.dist-info/entry_points.txt +3 -0
- agingclockbench-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|
+
)
|