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.
Files changed (30) hide show
  1. {pyaging-0.5.2 → pyaging-0.5.3}/PKG-INFO +18 -2
  2. {pyaging-0.5.2 → pyaging-0.5.3}/README.md +15 -1
  3. {pyaging-0.5.2 → pyaging-0.5.3}/pyproject.toml +2 -0
  4. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/__init__.py +1 -1
  5. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/_data.py +5 -2
  6. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/feature_ranges.json +2 -2
  7. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/_live.py +9 -10
  8. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/_logger.py +24 -26
  9. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/_models.py +66 -49
  10. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/__init__.py +2 -0
  11. pyaging-0.5.3/src/pyaging/predict/_cache.py +56 -0
  12. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_pred.py +29 -4
  13. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_pred_utils.py +49 -17
  14. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/predict/_transforms.py +8 -14
  15. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_preprocess.py +25 -56
  16. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_preprocess_utils.py +38 -21
  17. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/_tage.py +19 -17
  18. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_feature_ranges.py +1 -0
  19. pyaging-0.5.3/src/pyaging/utils/_files.py +16 -0
  20. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_hf.py +35 -3
  21. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/utils/_utils.py +24 -23
  22. {pyaging-0.5.2 → pyaging-0.5.3}/.gitignore +0 -0
  23. {pyaging-0.5.2 → pyaging-0.5.3}/LICENSE +0 -0
  24. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/data/__init__.py +0 -0
  25. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/logger/__init__.py +0 -0
  26. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/__init__.py +0 -0
  27. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/models/_base_models.py +0 -0
  28. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/preprocess/__init__.py +0 -0
  29. {pyaging-0.5.2 → pyaging-0.5.3}/src/pyaging/py.typed +0 -0
  30. {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.2
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 the `PYAGING_DATA_REVISION` environment variable to a release tag (e.g. `v0.3.1`) to pin downloads to a specific data revision for reproducibility; it defaults to `main`, the live data release.
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 the `PYAGING_DATA_REVISION` environment variable to a release tag (e.g. `v0.3.1`) to pin downloads to a specific data revision for reproducibility; it defaults to `main`, the live data release.
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
 
@@ -34,8 +34,10 @@ dependencies = [
34
34
  "pandas>=2.2.0",
35
35
  "torch>=2.2.0",
36
36
  "numpy>=2.0.0",
37
+ "scipy>=1.11",
37
38
  "anndata>=0.10.3",
38
39
  "huggingface-hub>=1.3,<2",
40
+ "httpx>=0.23,<1",
39
41
  "rich>=13.0",
40
42
  ]
41
43
 
@@ -4,4 +4,4 @@ from . import data, logger, models, utils
4
4
  from . import predict as pred
5
5
  from . import preprocess as pp
6
6
 
7
- __version__ = "0.5.2"
7
+ __version__ = "0.5.3"
@@ -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.parent.mkdir(parents=True, exist_ok=True)
73
- shutil.copy(cache_path, destination)
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, 8 = 16 or more)", "low": 0.0, "high": 8.0},
84
- "healthcare_visits_past_year": {"unit": "NHANES visit-count code (0 = none, 8 = 16 or more, 77 = refused, 99 = don't know)", "low": 0.0, "high": 99.0},
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 the Hub's own download bars wherever the display renders its
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
- were_enabled = (not verbose or animated) and not are_progress_bars_disabled()
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
- if were_enabled:
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
- """Dynamo-specific logger that sets up logging for the package."""
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
- yield
116
- self.namespace = previous
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 self.report_hook_percent_state is None:
300
- self.report_hook_percent_state = 0
301
-
302
- if ts == -1:
295
+ if ts <= 0:
296
+ self.report_hook_percent_state = None
303
297
  return
304
298
 
305
- cur_percent = rs * bn / ts
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(count=rs * bn, total=ts, indent_level=self.indent_level)
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("dynamo")
322
- temp_timer_logger = Logger("dynamo-temp-timer-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
- i = 0
338
+ total = len(generator) if isinstance(generator, Sized) else None
343
339
  prev_progress_percent = 0
344
- while i < len(generator):
345
- i += 1
346
- new_progress_percent = i / len(generator) * 100
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=len(generator),
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 next(iterator)
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
- set_logger_level("dynamo", level)
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
- sorted_vals, sorted_idx = torch.sort(values)
1282
- ranks = torch.empty_like(sorted_vals, dtype=values.dtype)
1300
+ return _average_ranks(values)
1283
1301
 
1284
- n = values.numel()
1285
- start = 0
1286
- while start < n:
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
- return ranks
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 = torch.nanmedian(x)
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 _expand_with_reference(self, x):
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
- ref_full = torch.zeros(len(self.base_model_features), device=x.device, dtype=x.dtype)
1362
- elif isinstance(self.full_reference_values, torch.Tensor):
1363
- ref_full = self.full_reference_values.to(device=x.device, dtype=x.dtype)
1364
- else:
1365
- ref_full = torch.tensor(self.full_reference_values, device=x.device, dtype=x.dtype)
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
- sorted_vals, sorted_idx = torch.sort(values)
1390
- ranks = torch.empty_like(sorted_vals, dtype=values.dtype)
1413
+ return _average_ranks(values)
1391
1414
 
1392
- n = values.numel()
1393
- start = 0
1394
- while start < n:
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
- return ranks
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 = torch.nanmedian(x)
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
- # lambda
1712
- lambda_ = torch.tensor(0.0192, device=x.device, dtype=x.dtype)
1713
- mortality_score = 1 - torch.exp(-torch.exp(x) * (torch.exp(120 * lambda_) - 1) / lambda_)
1714
- age = 141.50225 + torch.log(-0.00553 * torch.log(1 - mortality_score)) / 0.090165
1715
- return age
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 torch.tensor(float("nan"))
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 torch.tensor(float("nan"))
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
- model = load_clock(clock_name, device, dir, pipeline_logger)
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
- # Flush memory
175
- gc.collect()
176
- torch.cuda.empty_cache()
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)