pyaging 0.5.2__tar.gz → 0.5.3__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.
- {pyaging-0.5.2 → pyaging-0.5.3}/PKG-INFO +18 -2
- {pyaging-0.5.2 → pyaging-0.5.3}/README.md +15 -1
- {pyaging-0.5.2 → pyaging-0.5.3}/pyproject.toml +2 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/__init__.py +1 -1
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/_data.py +5 -2
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/feature_ranges.json +2 -2
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/_live.py +9 -10
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/_logger.py +24 -26
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/_models.py +66 -49
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/__init__.py +2 -0
- pyaging-0.5.3/src/pyaging/predict/_cache.py +56 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_pred.py +29 -4
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_pred_utils.py +49 -17
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_transforms.py +8 -14
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_preprocess.py +25 -56
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_preprocess_utils.py +38 -21
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_tage.py +19 -17
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_feature_ranges.py +1 -0
- pyaging-0.5.3/src/pyaging/utils/_files.py +16 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_hf.py +35 -3
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_utils.py +24 -23
- {pyaging-0.5.2 → pyaging-0.5.3}/.gitignore +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/LICENSE +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/__init__.py +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/__init__.py +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/__init__.py +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/_base_models.py +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/__init__.py +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/py.typed +0 -0
- {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyaging
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: A Python-based compendium of GPU-optimized aging clocks.
|
|
5
5
|
Project-URL: homepage, https://github.com/lucascamillomd/pyaging
|
|
6
6
|
Project-URL: repository, https://github.com/lucascamillomd/pyaging
|
|
@@ -19,11 +19,13 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
|
19
19
|
Classifier: Typing :: Typed
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
21
|
Requires-Dist: anndata>=0.10.3
|
|
22
|
+
Requires-Dist: httpx<1,>=0.23
|
|
22
23
|
Requires-Dist: huggingface-hub<2,>=1.3
|
|
23
24
|
Requires-Dist: numpy>=2.0.0
|
|
24
25
|
Requires-Dist: pandas>=2.2.0
|
|
25
26
|
Requires-Dist: rich>=13.0
|
|
26
27
|
Requires-Dist: scikit-learn>=1.3.2
|
|
28
|
+
Requires-Dist: scipy>=1.11
|
|
27
29
|
Requires-Dist: torch>=2.2.0
|
|
28
30
|
Provides-Extra: histone
|
|
29
31
|
Requires-Dist: pybigwig>=0.3.23; extra == 'histone'
|
|
@@ -79,7 +81,21 @@ pya.pred.predict_age(adata, ["Horvath2013", "AltumAge", "DunedinPACE"])
|
|
|
79
81
|
adata.obs.head()
|
|
80
82
|
```
|
|
81
83
|
|
|
82
|
-
Clock weights are downloaded on demand from per-clock repositories under the [`pyaging` Hugging Face organization](https://huggingface.co/pyaging) (example data comes from [`lucascamillomd/pyaging-data`](https://huggingface.co/lucascamillomd/pyaging-data)). Set
|
|
84
|
+
Clock weights are downloaded on demand from per-clock repositories under the [`pyaging` Hugging Face organization](https://huggingface.co/pyaging) (example data comes from [`lucascamillomd/pyaging-data`](https://huggingface.co/lucascamillomd/pyaging-data)). Set `PYAGING_DATA_REVISION` to an existing data release tag, such as `v0.5.2`, to pin downloads; it defaults to `main`. Package-only releases do not create new HF tags. Version 0.5.3 uses the existing model artifacts.
|
|
85
|
+
|
|
86
|
+
### Run the same clocks across datasets
|
|
87
|
+
|
|
88
|
+
Reuse loaded models with a bounded cache:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
clocks = ["Horvath2013", "AltumAge"]
|
|
92
|
+
cache = pya.pred.ClockCache(maxsize=len(clocks))
|
|
93
|
+
for adata in datasets: # Each dataset is a separate AnnData object.
|
|
94
|
+
pya.pred.predict_age(adata, clocks, clock_cache=cache)
|
|
95
|
+
cache.clear()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The cache avoids repeated download checks, deserialization, and device transfers. It holds at most `maxsize` models and keeps separate entries for each device and data revision. It is optional; ordinary calls retain no model cache. Clear it to refresh weights from a moving `main` revision. See the [prediction guide](https://pyaging.readthedocs.io/en/latest/pyaging.predict.html) for memory and cohort guidance.
|
|
83
99
|
|
|
84
100
|
## ❓ Can't find an aging clock?
|
|
85
101
|
|
|
@@ -48,7 +48,21 @@ pya.pred.predict_age(adata, ["Horvath2013", "AltumAge", "DunedinPACE"])
|
|
|
48
48
|
adata.obs.head()
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
Clock weights are downloaded on demand from per-clock repositories under the [`pyaging` Hugging Face organization](https://huggingface.co/pyaging) (example data comes from [`lucascamillomd/pyaging-data`](https://huggingface.co/lucascamillomd/pyaging-data)). Set
|
|
51
|
+
Clock weights are downloaded on demand from per-clock repositories under the [`pyaging` Hugging Face organization](https://huggingface.co/pyaging) (example data comes from [`lucascamillomd/pyaging-data`](https://huggingface.co/lucascamillomd/pyaging-data)). Set `PYAGING_DATA_REVISION` to an existing data release tag, such as `v0.5.2`, to pin downloads; it defaults to `main`. Package-only releases do not create new HF tags. Version 0.5.3 uses the existing model artifacts.
|
|
52
|
+
|
|
53
|
+
### Run the same clocks across datasets
|
|
54
|
+
|
|
55
|
+
Reuse loaded models with a bounded cache:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
clocks = ["Horvath2013", "AltumAge"]
|
|
59
|
+
cache = pya.pred.ClockCache(maxsize=len(clocks))
|
|
60
|
+
for adata in datasets: # Each dataset is a separate AnnData object.
|
|
61
|
+
pya.pred.predict_age(adata, clocks, clock_cache=cache)
|
|
62
|
+
cache.clear()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The cache avoids repeated download checks, deserialization, and device transfers. It holds at most `maxsize` models and keeps separate entries for each device and data revision. It is optional; ordinary calls retain no model cache. Clear it to refresh weights from a moving `main` revision. See the [prediction guide](https://pyaging.readthedocs.io/en/latest/pyaging.predict.html) for memory and cohort guidance.
|
|
52
66
|
|
|
53
67
|
## ❓ Can't find an aging clock?
|
|
54
68
|
|
|
@@ -2,6 +2,7 @@ import shutil
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from ..logger._live import SimpleStep, display_enabled, live_step, quiet_hf_bars
|
|
5
|
+
from ..utils._files import atomic_output_path
|
|
5
6
|
from ..utils._hf import download_hf_file
|
|
6
7
|
|
|
7
8
|
_EXAMPLE_DATA_FILENAMES = {
|
|
@@ -63,13 +64,15 @@ def download_example_data(data_type: str, dir: str = "pyaging_data", verbose: bo
|
|
|
63
64
|
enabled = display_enabled(verbose)
|
|
64
65
|
filename = _EXAMPLE_DATA_FILENAMES[data_type]
|
|
65
66
|
destination = Path(dir) / filename
|
|
67
|
+
if destination.is_dir():
|
|
68
|
+
raise IsADirectoryError(destination)
|
|
66
69
|
if destination.exists():
|
|
67
70
|
SimpleStep(filename, enabled=enabled).done(f"example data already at {destination}")
|
|
68
71
|
return str(destination)
|
|
69
72
|
|
|
70
73
|
with quiet_hf_bars(verbose), live_step(f"downloading {filename}", verbose) as (step, pipeline_logger):
|
|
71
74
|
cache_path = download_hf_file(filename, dir, pipeline_logger, indent_level=1)
|
|
72
|
-
destination
|
|
73
|
-
|
|
75
|
+
with atomic_output_path(destination) as temporary_path:
|
|
76
|
+
shutil.copy(cache_path, temporary_path)
|
|
74
77
|
step.done(f"example data at {destination}")
|
|
75
78
|
return str(destination)
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"grimage2timp1": {"unit": "pg/mL", "low": 1000.0, "high": 5000000.0},
|
|
81
81
|
"hdl_cholesterol": {"unit": "mmol/L", "low": 0.1, "high": 5.0},
|
|
82
82
|
"health_compared_to_one_year_ago": {"unit": "NHANES change code (1 = better, 2 = worse, 3 = same, 7 = refused, 9 = don't know)", "low": 1.0, "high": 9.0},
|
|
83
|
-
"healthcare_use_index": {"unit": "NHANES visit-count code (0 = none,
|
|
84
|
-
"healthcare_visits_past_year": {"unit": "NHANES visit-count code (0 = none,
|
|
83
|
+
"healthcare_use_index": {"unit": "NHANES 1999-2000 HUQ050 visit-count code (0 = none, 5 = 13 or more)", "low": 0.0, "high": 5.0},
|
|
84
|
+
"healthcare_visits_past_year": {"unit": "NHANES 1999-2000 HUQ050 visit-count code (0 = none, 1 = 1, 2 = 2 to 3, 3 = 4 to 9, 4 = 10 to 12, 5 = 13 or more, 77 = refused, 99 = don't know); collapse the 0 to 8 HUQ051 scale of later cycles onto it first", "low": 0.0, "high": 99.0},
|
|
85
85
|
"hematocrit": {"unit": "%", "low": 5.0, "high": 75.0},
|
|
86
86
|
"hemoglobin": {"unit": "g/dL", "low": 2.0, "high": 25.0},
|
|
87
87
|
"hemoglobin_a1c": {"unit": "%", "low": 2.0, "high": 20.0},
|
|
@@ -21,6 +21,7 @@ import contextlib
|
|
|
21
21
|
import sys
|
|
22
22
|
import threading
|
|
23
23
|
import time
|
|
24
|
+
from contextvars import ContextVar
|
|
24
25
|
|
|
25
26
|
from rich.console import Console, Group
|
|
26
27
|
|
|
@@ -52,6 +53,7 @@ DOT = "⏺" if sys.platform == "darwin" else "●"
|
|
|
52
53
|
ELBOW = "⎿"
|
|
53
54
|
|
|
54
55
|
_console = Console()
|
|
56
|
+
_hf_bars_disabled = ContextVar("pyaging_hf_bars_disabled", default=False)
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
class Sparkle:
|
|
@@ -107,20 +109,17 @@ def display_enabled(verbose) -> bool:
|
|
|
107
109
|
|
|
108
110
|
@contextlib.contextmanager
|
|
109
111
|
def quiet_hf_bars(verbose=True):
|
|
110
|
-
"""Suppress
|
|
111
|
-
own progress, and always at verbose=False. Non-interactive verbose runs
|
|
112
|
-
keep the bars: they are the only live progress signal there."""
|
|
113
|
-
from huggingface_hub.utils import are_progress_bars_disabled, disable_progress_bars, enable_progress_bars
|
|
112
|
+
"""Suppress bars for this run without changing global Hub settings.
|
|
114
113
|
|
|
114
|
+
Non-interactive verbose runs keep the bars: they are the only live
|
|
115
|
+
progress signal there. Nested quiet contexts remain quiet.
|
|
116
|
+
"""
|
|
115
117
|
animated = _console.is_jupyter or _console.is_interactive
|
|
116
|
-
|
|
117
|
-
if were_enabled:
|
|
118
|
-
disable_progress_bars()
|
|
118
|
+
token = _hf_bars_disabled.set(_hf_bars_disabled.get() or not verbose or animated)
|
|
119
119
|
try:
|
|
120
120
|
yield
|
|
121
121
|
finally:
|
|
122
|
-
|
|
123
|
-
enable_progress_bars()
|
|
122
|
+
_hf_bars_disabled.reset(token)
|
|
124
123
|
|
|
125
124
|
|
|
126
125
|
def _bar(completed: float, total: float | None, width: int = 24, pulse: bool = False):
|
|
@@ -143,7 +142,7 @@ def live_step(label: str, verbose):
|
|
|
143
142
|
The step renders animated when interactive, prints final lines when
|
|
144
143
|
piped, and is fully silent at verbose=False - callers keep one code path.
|
|
145
144
|
"""
|
|
146
|
-
with SimpleStep(label, enabled=display_enabled(verbose)) as step:
|
|
145
|
+
with quiet_hf_bars(verbose), SimpleStep(label, enabled=display_enabled(verbose)) as step:
|
|
147
146
|
yield step, DisplayLogger(step.warn)
|
|
148
147
|
|
|
149
148
|
|
|
@@ -4,6 +4,7 @@ import functools
|
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
6
|
import time
|
|
7
|
+
from collections.abc import Sized
|
|
7
8
|
from contextlib import contextmanager
|
|
8
9
|
|
|
9
10
|
|
|
@@ -45,7 +46,7 @@ def format_logging_message(msg, logging_level, indent_level=1, indent_space_num=
|
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
class Logger:
|
|
48
|
-
"""
|
|
49
|
+
"""Logger that sets up pyaging's legacy text progress output."""
|
|
49
50
|
|
|
50
51
|
FORMAT = "%(message)s"
|
|
51
52
|
|
|
@@ -69,13 +70,6 @@ class Logger:
|
|
|
69
70
|
self.logger.propagate = False
|
|
70
71
|
self.log_time()
|
|
71
72
|
|
|
72
|
-
# Other global initialization
|
|
73
|
-
silence_logger("anndata")
|
|
74
|
-
silence_logger("h5py")
|
|
75
|
-
silence_logger("numba")
|
|
76
|
-
silence_logger("pysam")
|
|
77
|
-
silence_logger("pystan")
|
|
78
|
-
|
|
79
73
|
if level is not None:
|
|
80
74
|
self.logger.setLevel(level)
|
|
81
75
|
else:
|
|
@@ -112,8 +106,10 @@ class Logger:
|
|
|
112
106
|
"""
|
|
113
107
|
previous = self.namespace
|
|
114
108
|
self.namespace = namespace
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
self.namespace = previous
|
|
117
113
|
|
|
118
114
|
def namespace_message(self, message):
|
|
119
115
|
"""Add namespace information at the beginning of the logging message.
|
|
@@ -296,16 +292,17 @@ class Logger:
|
|
|
296
292
|
ts :
|
|
297
293
|
total size
|
|
298
294
|
"""
|
|
299
|
-
if
|
|
300
|
-
self.report_hook_percent_state =
|
|
301
|
-
|
|
302
|
-
if ts == -1:
|
|
295
|
+
if ts <= 0:
|
|
296
|
+
self.report_hook_percent_state = None
|
|
303
297
|
return
|
|
304
298
|
|
|
305
|
-
|
|
299
|
+
if bn == 0 or self.report_hook_percent_state is None:
|
|
300
|
+
self.report_hook_percent_state = 0
|
|
301
|
+
|
|
302
|
+
cur_percent = min(rs * bn / ts, 1)
|
|
306
303
|
|
|
307
304
|
if cur_percent - self.report_hook_percent_state > 0.01:
|
|
308
|
-
self.report_progress(
|
|
305
|
+
self.report_progress(percent=cur_percent * 100, indent_level=self.indent_level)
|
|
309
306
|
self.report_hook_percent_state = cur_percent
|
|
310
307
|
if rs * bn >= ts:
|
|
311
308
|
self.report_hook_percent_state = None
|
|
@@ -318,8 +315,8 @@ class LoggerManager:
|
|
|
318
315
|
CRITICAL = logging.CRITICAL
|
|
319
316
|
EXCEPTION = logging.ERROR
|
|
320
317
|
|
|
321
|
-
main_logger = Logger("
|
|
322
|
-
temp_timer_logger = Logger("
|
|
318
|
+
main_logger = Logger("pyaging")
|
|
319
|
+
temp_timer_logger = Logger("pyaging.temp-timer")
|
|
323
320
|
|
|
324
321
|
@staticmethod
|
|
325
322
|
def get_main_logger():
|
|
@@ -337,23 +334,24 @@ class LoggerManager:
|
|
|
337
334
|
def progress_logger(generator, logger=None, progress_name="", indent_level=1):
|
|
338
335
|
if logger is None:
|
|
339
336
|
logger = LoggerManager.get_temp_timer_logger()
|
|
340
|
-
iterator = iter(generator)
|
|
341
337
|
logger.log_time()
|
|
342
|
-
|
|
338
|
+
total = len(generator) if isinstance(generator, Sized) else None
|
|
343
339
|
prev_progress_percent = 0
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
340
|
+
for i, item in enumerate(generator, start=1):
|
|
341
|
+
if total is None or total == 0:
|
|
342
|
+
yield item
|
|
343
|
+
continue
|
|
344
|
+
new_progress_percent = i / total * 100
|
|
347
345
|
# report every `interval` percent
|
|
348
346
|
if new_progress_percent - prev_progress_percent > 1 or new_progress_percent >= 100:
|
|
349
347
|
logger.report_progress(
|
|
350
348
|
count=i,
|
|
351
|
-
total=
|
|
349
|
+
total=total,
|
|
352
350
|
progress_name=progress_name,
|
|
353
351
|
indent_level=indent_level,
|
|
354
352
|
)
|
|
355
353
|
prev_progress_percent = new_progress_percent
|
|
356
|
-
yield
|
|
354
|
+
yield item
|
|
357
355
|
|
|
358
356
|
|
|
359
357
|
def main_info(message, indent_level=1):
|
|
@@ -435,4 +433,4 @@ def main_info_verbose_timeit(msg):
|
|
|
435
433
|
|
|
436
434
|
|
|
437
435
|
def main_set_level(level):
|
|
438
|
-
|
|
436
|
+
LoggerManager.main_logger.setLevel(level)
|
|
@@ -42,6 +42,24 @@ LINAGE2_COMORBIDITY_ITEMS = (
|
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _global_median(x):
|
|
46
|
+
"""Keep the rank clocks' lower median and all-missing zero fallback."""
|
|
47
|
+
median = torch.nanmedian(x)
|
|
48
|
+
return torch.where(torch.isnan(median), torch.zeros_like(median), median)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _average_ranks(values):
|
|
52
|
+
"""Assign average one-based ranks without scalar tensor comparisons."""
|
|
53
|
+
sorted_values, order = torch.sort(values)
|
|
54
|
+
_, counts = torch.unique_consecutive(sorted_values, return_counts=True)
|
|
55
|
+
ends = counts.cumsum(0)
|
|
56
|
+
starts = ends - counts
|
|
57
|
+
averages = (starts + ends + 1).to(values.dtype) / 2
|
|
58
|
+
ranks = torch.empty_like(values)
|
|
59
|
+
ranks[order] = torch.repeat_interleave(averages, counts, output_size=values.numel())
|
|
60
|
+
return ranks
|
|
61
|
+
|
|
62
|
+
|
|
45
63
|
class AltumAge(pyagingModel):
|
|
46
64
|
def __init__(self):
|
|
47
65
|
super().__init__()
|
|
@@ -1138,6 +1156,7 @@ class PCGrimAge(pyagingModel):
|
|
|
1138
1156
|
self.features_PCTIMP1 = None
|
|
1139
1157
|
|
|
1140
1158
|
def forward(self, x):
|
|
1159
|
+
x = self.preprocess(x)
|
|
1141
1160
|
CpGs = x[:, :-2]
|
|
1142
1161
|
Female = x[:, -2].unsqueeze(1)
|
|
1143
1162
|
Age = x[:, -1].unsqueeze(1)
|
|
@@ -1278,28 +1297,21 @@ class Pasta(pyagingModel):
|
|
|
1278
1297
|
"""
|
|
1279
1298
|
Assign average ranks (1-based) per vector, handling ties.
|
|
1280
1299
|
"""
|
|
1281
|
-
|
|
1282
|
-
ranks = torch.empty_like(sorted_vals, dtype=values.dtype)
|
|
1300
|
+
return _average_ranks(values)
|
|
1283
1301
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
end = start + 1
|
|
1288
|
-
while end < n and sorted_vals[end] == sorted_vals[start]:
|
|
1289
|
-
end += 1
|
|
1290
|
-
avg_rank = (start + end - 1) / 2.0 + 1.0
|
|
1291
|
-
ranks[sorted_idx[start:end]] = avg_rank
|
|
1292
|
-
start = end
|
|
1302
|
+
def prepare_cohort_context(self, x):
|
|
1303
|
+
"""Compute the missing-value fill once across the complete cohort."""
|
|
1304
|
+
return _global_median(x)
|
|
1293
1305
|
|
|
1294
|
-
|
|
1306
|
+
def predict_with_cohort_context(self, x, context):
|
|
1307
|
+
"""Score a batch with its cohort's median without storing run state."""
|
|
1308
|
+
return self.postprocess(self.base_model(self.preprocess(x, context)))
|
|
1295
1309
|
|
|
1296
|
-
def preprocess(self, x):
|
|
1310
|
+
def preprocess(self, x, median=None):
|
|
1297
1311
|
"""
|
|
1298
1312
|
Fill missing values with the global median then rank-normalize per sample.
|
|
1299
1313
|
"""
|
|
1300
|
-
median =
|
|
1301
|
-
if torch.isnan(median):
|
|
1302
|
-
median = torch.tensor(0.0, device=x.device, dtype=x.dtype)
|
|
1314
|
+
median = _global_median(x) if median is None else median.to(device=x.device, dtype=x.dtype)
|
|
1303
1315
|
x = torch.where(torch.isnan(x), median, x)
|
|
1304
1316
|
|
|
1305
1317
|
ranked = torch.empty_like(x, dtype=x.dtype)
|
|
@@ -1349,21 +1361,33 @@ class PastaMouse(Pasta):
|
|
|
1349
1361
|
else:
|
|
1350
1362
|
self.reference_values = [self.full_reference_values[i] for i in self.mouse_feature_indices]
|
|
1351
1363
|
|
|
1352
|
-
def
|
|
1353
|
-
"""
|
|
1354
|
-
Reconstruct the full 8113-length input expected by the base model by
|
|
1355
|
-
inserting reference values for human-only genes.
|
|
1356
|
-
"""
|
|
1364
|
+
def _full_reference_tensor(self, x):
|
|
1357
1365
|
if self.base_model_features is None or self.mouse_feature_indices is None:
|
|
1358
1366
|
raise ValueError("PastaMouse must be configured with set_mouse_features before inference.")
|
|
1359
1367
|
|
|
1360
1368
|
if self.full_reference_values is None:
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1369
|
+
return torch.zeros(len(self.base_model_features), device=x.device, dtype=x.dtype)
|
|
1370
|
+
return torch.as_tensor(self.full_reference_values, device=x.device, dtype=x.dtype)
|
|
1371
|
+
|
|
1372
|
+
def prepare_cohort_context(self, x):
|
|
1373
|
+
"""Include human reference values without expanding the full cohort."""
|
|
1374
|
+
reference = self._full_reference_tensor(x)
|
|
1375
|
+
inserted = torch.ones(reference.shape, dtype=torch.bool, device=x.device)
|
|
1376
|
+
inserted[self.mouse_feature_indices] = False
|
|
1377
|
+
# Missing references do not affect nanmedian, which is the common case.
|
|
1378
|
+
reference = reference[inserted & ~torch.isnan(reference)]
|
|
1379
|
+
values = torch.cat([x.reshape(-1), reference.repeat(x.size(0))]) if reference.numel() else x
|
|
1380
|
+
return _global_median(values)
|
|
1381
|
+
|
|
1382
|
+
def predict_with_cohort_context(self, x, context):
|
|
1383
|
+
return super().predict_with_cohort_context(self._expand_with_reference(x), context)
|
|
1366
1384
|
|
|
1385
|
+
def _expand_with_reference(self, x):
|
|
1386
|
+
"""
|
|
1387
|
+
Reconstruct the full 8113-length input expected by the base model by
|
|
1388
|
+
inserting reference values for human-only genes.
|
|
1389
|
+
"""
|
|
1390
|
+
ref_full = self._full_reference_tensor(x)
|
|
1367
1391
|
full_x = ref_full.unsqueeze(0).repeat(x.size(0), 1)
|
|
1368
1392
|
full_x[:, self.mouse_feature_indices] = x
|
|
1369
1393
|
return full_x
|
|
@@ -1386,28 +1410,21 @@ class Reg(pyagingModel):
|
|
|
1386
1410
|
"""
|
|
1387
1411
|
Assign average ranks (1-based) per vector, handling ties.
|
|
1388
1412
|
"""
|
|
1389
|
-
|
|
1390
|
-
ranks = torch.empty_like(sorted_vals, dtype=values.dtype)
|
|
1413
|
+
return _average_ranks(values)
|
|
1391
1414
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
end = start + 1
|
|
1396
|
-
while end < n and sorted_vals[end] == sorted_vals[start]:
|
|
1397
|
-
end += 1
|
|
1398
|
-
avg_rank = (start + end - 1) / 2.0 + 1.0
|
|
1399
|
-
ranks[sorted_idx[start:end]] = avg_rank
|
|
1400
|
-
start = end
|
|
1415
|
+
def prepare_cohort_context(self, x):
|
|
1416
|
+
"""Compute the missing-value fill once across the complete cohort."""
|
|
1417
|
+
return _global_median(x)
|
|
1401
1418
|
|
|
1402
|
-
|
|
1419
|
+
def predict_with_cohort_context(self, x, context):
|
|
1420
|
+
"""Score a batch with its cohort's median without storing run state."""
|
|
1421
|
+
return self.postprocess(self.base_model(self.preprocess(x, context)))
|
|
1403
1422
|
|
|
1404
|
-
def preprocess(self, x):
|
|
1423
|
+
def preprocess(self, x, median=None):
|
|
1405
1424
|
"""
|
|
1406
1425
|
Fill missing values with the global median then rank-normalize per sample.
|
|
1407
1426
|
"""
|
|
1408
|
-
median =
|
|
1409
|
-
if torch.isnan(median):
|
|
1410
|
-
median = torch.tensor(0.0, device=x.device, dtype=x.dtype)
|
|
1427
|
+
median = _global_median(x) if median is None else median.to(device=x.device, dtype=x.dtype)
|
|
1411
1428
|
x = torch.where(torch.isnan(x), median, x)
|
|
1412
1429
|
|
|
1413
1430
|
ranked = torch.empty_like(x, dtype=x.dtype)
|
|
@@ -1708,11 +1725,11 @@ class PhenoAge(pyagingModel):
|
|
|
1708
1725
|
Applies a convertion from a CDF of the mortality score from a Gompertz
|
|
1709
1726
|
distribution to phenotypic age.
|
|
1710
1727
|
"""
|
|
1711
|
-
#
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
return
|
|
1728
|
+
# log(1 - mortality) equals the negative Gompertz hazard exactly.
|
|
1729
|
+
# Cancel the nested exp/log terms before finite precision rounds the
|
|
1730
|
+
# mortality to zero or one, preserving the published constants.
|
|
1731
|
+
log_scale = math.log(0.00553 * math.expm1(120 * 0.0192) / 0.0192)
|
|
1732
|
+
return 141.50225 + (x + log_scale) / 0.090165
|
|
1716
1733
|
|
|
1717
1734
|
|
|
1718
1735
|
class PhenoAgeSaoPaulo(pyagingModel):
|
|
@@ -2273,7 +2290,7 @@ class stemTOC(pyagingModel):
|
|
|
2273
2290
|
quantiles = []
|
|
2274
2291
|
for row in x:
|
|
2275
2292
|
filtered_row = row[row != -1]
|
|
2276
|
-
quantile_95 = torch.quantile(filtered_row, 0.95) if len(filtered_row) > 0 else
|
|
2293
|
+
quantile_95 = torch.quantile(filtered_row, 0.95) if len(filtered_row) > 0 else x.new_tensor(float("nan"))
|
|
2277
2294
|
quantiles.append(quantile_95)
|
|
2278
2295
|
return torch.vstack(quantiles)
|
|
2279
2296
|
|
|
@@ -2290,7 +2307,7 @@ class epiTOC1(pyagingModel):
|
|
|
2290
2307
|
means = []
|
|
2291
2308
|
for row in x:
|
|
2292
2309
|
filtered_row = row[row != -1]
|
|
2293
|
-
mean = torch.mean(filtered_row) if len(filtered_row) > 0 else
|
|
2310
|
+
mean = torch.mean(filtered_row) if len(filtered_row) > 0 else x.new_tensor(float("nan"))
|
|
2294
2311
|
means.append(mean)
|
|
2295
2312
|
return torch.vstack(means)
|
|
2296
2313
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# pyaging/predict/__init__.py
|
|
2
2
|
|
|
3
|
+
from ._cache import ClockCache
|
|
3
4
|
from ._pred import predict_age
|
|
4
5
|
from ._pred_utils import (
|
|
5
6
|
add_pred_ages_and_clock_metadata_adata,
|
|
@@ -21,6 +22,7 @@ from ._transforms import (
|
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
__all__ = [
|
|
25
|
+
"ClockCache",
|
|
24
26
|
"add_pred_ages_and_clock_metadata_adata",
|
|
25
27
|
"binarize",
|
|
26
28
|
"check_feature_ranges",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Explicit, bounded reuse of prepared models across prediction calls."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from numbers import Integral
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
from ..utils._hf import get_data_revision
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClockCache:
|
|
12
|
+
"""Keep a bounded number of prepared clocks for repeated datasets.
|
|
13
|
+
|
|
14
|
+
Pass the same instance to ``predict_age(..., clock_cache=cache)`` to reuse
|
|
15
|
+
loaded models, including their device transfer and evaluation setup. Keys
|
|
16
|
+
include the lowercase clock name, device, and ``PYAGING_DATA_REVISION``.
|
|
17
|
+
Input matrices and cohort preprocessing are never cached.
|
|
18
|
+
|
|
19
|
+
``maxsize`` is a positive integer, defaulting to 2. A cache miss evicts the
|
|
20
|
+
least recently used clock *before* loading when the cache is full, so a
|
|
21
|
+
failed replacement load may leave one fewer cached model. Models remain
|
|
22
|
+
on their selected device until eviction, ``clear()``, or cache disposal.
|
|
23
|
+
|
|
24
|
+
The cache represents a snapshot: call ``clear()`` to pick up changed files
|
|
25
|
+
at a mutable Hugging Face revision such as ``main``. No global cache is
|
|
26
|
+
created. An instance is intended for sequential calls, not shared threads.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, maxsize: int = 2):
|
|
30
|
+
if isinstance(maxsize, bool) or not isinstance(maxsize, Integral) or maxsize <= 0:
|
|
31
|
+
raise ValueError("maxsize must be a positive integer.")
|
|
32
|
+
self._maxsize = int(maxsize)
|
|
33
|
+
self._models = OrderedDict()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def maxsize(self) -> int:
|
|
37
|
+
"""Maximum number of prepared models held by this cache."""
|
|
38
|
+
return self._maxsize
|
|
39
|
+
|
|
40
|
+
def clear(self) -> None:
|
|
41
|
+
"""Release all model references held by this cache."""
|
|
42
|
+
self._models.clear()
|
|
43
|
+
|
|
44
|
+
def _get_or_load(self, clock_name, device, load):
|
|
45
|
+
device = torch.device(device)
|
|
46
|
+
if device.type == "cuda" and device.index is None:
|
|
47
|
+
device = torch.device("cuda", torch.cuda.current_device())
|
|
48
|
+
key = (clock_name.lower(), str(device), get_data_revision())
|
|
49
|
+
if key in self._models:
|
|
50
|
+
self._models.move_to_end(key)
|
|
51
|
+
return self._models[key]
|
|
52
|
+
if len(self._models) >= self.maxsize:
|
|
53
|
+
self._models.popitem(last=False)
|
|
54
|
+
model = load()
|
|
55
|
+
self._models[key] = model
|
|
56
|
+
return model
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import gc
|
|
2
|
+
from functools import partial
|
|
2
3
|
|
|
3
4
|
import anndata
|
|
4
5
|
import torch
|
|
5
6
|
|
|
6
7
|
from ..logger._live import ClockRunDisplay, DisplayLogger, display_enabled, quiet_hf_bars
|
|
8
|
+
from ._cache import ClockCache
|
|
7
9
|
from ._pred_utils import (
|
|
10
|
+
_validate_batch_size,
|
|
8
11
|
add_pred_ages_and_clock_metadata_adata,
|
|
9
12
|
apply_cohort_transform,
|
|
10
13
|
build_cohort_feature_matrix,
|
|
@@ -23,6 +26,8 @@ def predict_age(
|
|
|
23
26
|
batch_size: int = 1024,
|
|
24
27
|
clean: bool = True,
|
|
25
28
|
verbose: bool = True,
|
|
29
|
+
*,
|
|
30
|
+
clock_cache: ClockCache | None = None,
|
|
26
31
|
) -> None:
|
|
27
32
|
"""
|
|
28
33
|
Predicts biological age using specified aging clocks.
|
|
@@ -56,6 +61,11 @@ def predict_age(
|
|
|
56
61
|
notebooks and terminals, a plain summary when output is captured,
|
|
57
62
|
and fully silent when False. Defaults to True.
|
|
58
63
|
|
|
64
|
+
clock_cache: ClockCache or None
|
|
65
|
+
Optional bounded cache of prepared models to reuse across datasets.
|
|
66
|
+
Keep the same cache instance for repeated calls. Clear it to refresh
|
|
67
|
+
weights at mutable revisions such as ``main``. Defaults to no reuse.
|
|
68
|
+
|
|
59
69
|
Returns
|
|
60
70
|
-------
|
|
61
71
|
None
|
|
@@ -87,6 +97,9 @@ def predict_age(
|
|
|
87
97
|
the samples to centre against. Without it the cohort centres on every sample. Predictions are
|
|
88
98
|
differences against that reference, not absolute values.
|
|
89
99
|
|
|
100
|
+
Pasta, Reg, and PastaMouse fill missing values with a median computed over the whole cohort
|
|
101
|
+
before ranking each sample. Their inference batch size does not change that reference.
|
|
102
|
+
|
|
90
103
|
The function automatically handles the transfer of data and models to the appropriate compute
|
|
91
104
|
device (CPU or GPU) based on system configuration.
|
|
92
105
|
|
|
@@ -97,6 +110,10 @@ def predict_age(
|
|
|
97
110
|
>>> adata.obs["horvath2013"] # Access predicted ages by clock name
|
|
98
111
|
|
|
99
112
|
"""
|
|
113
|
+
_validate_batch_size(batch_size)
|
|
114
|
+
if adata.n_obs == 0:
|
|
115
|
+
raise ValueError("Prediction requires at least one sample.")
|
|
116
|
+
|
|
100
117
|
# Ensure clock_names is a list with lowercase names
|
|
101
118
|
if isinstance(clock_names, str):
|
|
102
119
|
clock_names = [clock_names]
|
|
@@ -118,7 +135,12 @@ def predict_age(
|
|
|
118
135
|
pipeline_logger = DisplayLogger(lambda m, name=clock_name: display.warn(name, m))
|
|
119
136
|
|
|
120
137
|
# Load and prepare the clock
|
|
121
|
-
|
|
138
|
+
if clock_cache is None:
|
|
139
|
+
model = load_clock(clock_name, device, dir, pipeline_logger)
|
|
140
|
+
else:
|
|
141
|
+
model = clock_cache._get_or_load(
|
|
142
|
+
clock_name, device, partial(load_clock, clock_name, device, dir, pipeline_logger)
|
|
143
|
+
)
|
|
122
144
|
|
|
123
145
|
# Clocks saved before either attribute existed lack both.
|
|
124
146
|
transform_name = getattr(model, "cohort_transform", None)
|
|
@@ -171,9 +193,12 @@ def predict_age(
|
|
|
171
193
|
if clean:
|
|
172
194
|
del adata.obsm[f"X_{clock_name}"]
|
|
173
195
|
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
# Release references before collecting memory and loading the next
|
|
197
|
+
# clock, so two large models need not coexist on the GPU.
|
|
198
|
+
del model, predicted_ages_tensor
|
|
199
|
+
if clock_cache is None:
|
|
200
|
+
gc.collect()
|
|
201
|
+
torch.cuda.empty_cache()
|
|
177
202
|
|
|
178
203
|
display.finish_clock(clock_name)
|
|
179
204
|
display.finish(n_samples=adata.n_obs)
|