pyaging 0.5.2__tar.gz → 0.5.4__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 (34) hide show
  1. {pyaging-0.5.2 → pyaging-0.5.4}/.gitignore +5 -1
  2. {pyaging-0.5.2 → pyaging-0.5.4}/PKG-INFO +18 -2
  3. {pyaging-0.5.2 → pyaging-0.5.4}/README.md +15 -1
  4. {pyaging-0.5.2 → pyaging-0.5.4}/pyproject.toml +3 -1
  5. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/__init__.py +1 -1
  6. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/data/_data.py +5 -2
  7. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/data/feature_ranges.json +3 -2
  8. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/logger/_live.py +9 -10
  9. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/logger/_logger.py +24 -26
  10. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/models/__init__.py +29 -0
  11. pyaging-0.5.4/src/pyaging/models/_author_extensions.py +28 -0
  12. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/models/_models.py +66 -49
  13. pyaging-0.5.4/src/pyaging/models/_organage.py +33 -0
  14. pyaging-0.5.4/src/pyaging/models/_pc_extensions.py +67 -0
  15. pyaging-0.5.4/src/pyaging/models/_replication.py +88 -0
  16. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/predict/__init__.py +2 -0
  17. pyaging-0.5.4/src/pyaging/predict/_cache.py +56 -0
  18. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/predict/_pred.py +29 -14
  19. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/predict/_pred_utils.py +51 -41
  20. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/predict/_transforms.py +8 -14
  21. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/preprocess/_preprocess.py +25 -65
  22. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/preprocess/_preprocess_utils.py +38 -21
  23. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/preprocess/_tage.py +19 -17
  24. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/utils/_feature_ranges.py +1 -0
  25. pyaging-0.5.4/src/pyaging/utils/_files.py +16 -0
  26. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/utils/_hf.py +39 -8
  27. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/utils/_utils.py +24 -23
  28. {pyaging-0.5.2 → pyaging-0.5.4}/LICENSE +0 -0
  29. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/data/__init__.py +0 -0
  30. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/logger/__init__.py +0 -0
  31. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/models/_base_models.py +0 -0
  32. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/preprocess/__init__.py +0 -0
  33. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/py.typed +0 -0
  34. {pyaging-0.5.2 → pyaging-0.5.4}/src/pyaging/utils/__init__.py +0 -0
@@ -10,6 +10,10 @@ __pycache__/
10
10
  # Tool caches
11
11
  .pytest_cache/
12
12
  .ruff_cache/
13
+ .coverage
14
+ .coverage.*
15
+ coverage.xml
16
+ htmlcov/
13
17
 
14
18
  # Packaging artifacts
15
19
  build/
@@ -35,7 +39,7 @@ example_data/
35
39
 
36
40
  # Generated clock weights and aggregate metadata (published to Hugging Face)
37
41
  clocks/weights/
38
- clocks/metadata/all_clock_metadata.pt
42
+ clocks/metadata/*.pt
39
43
  clocks/blood_chemistry_example.pkl
40
44
  clocks/blood_chemistry_example.csv
41
45
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyaging
3
- Version: 0.5.2
3
+ Version: 0.5.4
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.4`, to pin downloads; it defaults to `main`. Version 0.5.4 adds CellDRIFT, MiAge, PCBrainAge, IntrinClock370, PhenoAge partitions, standalone PCGrimAge proxies, and 90 proteomic OrganAge outputs. See the [input and output guide](https://pyaging.readthedocs.io/en/latest/clocks_0_5_4.html). Model weights retain the original authors’ licensing terms.
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.4`, to pin downloads; it defaults to `main`. Version 0.5.4 adds CellDRIFT, MiAge, PCBrainAge, IntrinClock370, PhenoAge partitions, standalone PCGrimAge proxies, and 90 proteomic OrganAge outputs. See the [input and output guide](https://pyaging.readthedocs.io/en/latest/clocks_0_5_4.html). Model weights retain the original authors’ licensing terms.
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
 
@@ -100,7 +102,7 @@ only-include = ["src/pyaging", "README.md", "LICENSE", "pyproject.toml"]
100
102
 
101
103
  [tool.ruff]
102
104
  line-length = 120
103
- extend-exclude = ["*.ipynb", "dev", "docs/superpowers"]
105
+ extend-exclude = ["*.ipynb"]
104
106
 
105
107
  [tool.ruff.lint]
106
108
  select = [
@@ -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.4"
@@ -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)
@@ -2,6 +2,7 @@
2
2
  "schema_version": 1,
3
3
  "modality_defaults": {
4
4
  "DNA methylation": {"unit": "beta value", "low": 0.0, "high": 1.0},
5
+ "proteomics": {"unit": "NPX", "low": null, "high": null},
5
6
  "transcriptomics": {"unit": null, "low": 0.0, "high": null},
6
7
  "transcriptomics (relative)": {"unit": null, "low": null, "high": null},
7
8
  "histone modification": {"unit": null, "low": 0.0, "high": null},
@@ -80,8 +81,8 @@
80
81
  "grimage2timp1": {"unit": "pg/mL", "low": 1000.0, "high": 5000000.0},
81
82
  "hdl_cholesterol": {"unit": "mmol/L", "low": 0.1, "high": 5.0},
82
83
  "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},
84
+ "healthcare_use_index": {"unit": "NHANES 1999-2000 HUQ050 visit-count code (0 = none, 5 = 13 or more)", "low": 0.0, "high": 5.0},
85
+ "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
86
  "hematocrit": {"unit": "%", "low": 5.0, "high": 75.0},
86
87
  "hemoglobin": {"unit": "g/dL", "low": 2.0, "high": 25.0},
87
88
  "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 for pyaging's text output and file logging."""
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)
@@ -1,5 +1,6 @@
1
1
  # pyaging/models/__init__.py
2
2
 
3
+ from ._author_extensions import DNAmPhenoAgeNonPRC, DNAmPhenoAgePRC, IntrinClock370
3
4
  from ._base_models import (
4
5
  AltumAgeNeuralNetwork,
5
6
  LinearModel,
@@ -173,8 +174,36 @@ from ._models import (
173
174
  epiTOC2,
174
175
  stemTOC,
175
176
  )
177
+ from ._organage import OrganAge
178
+ from ._pc_extensions import (
179
+ PCBrainAge,
180
+ PCGrimAgeADM,
181
+ PCGrimAgeB2M,
182
+ PCGrimAgeCystatinC,
183
+ PCGrimAgeGDF15,
184
+ PCGrimAgeLeptin,
185
+ PCGrimAgePackYrs,
186
+ PCGrimAgePAI1,
187
+ PCGrimAgeTIMP1,
188
+ )
189
+ from ._replication import CellDRIFT, MiAge
176
190
 
177
191
  __all__ = [
192
+ "CellDRIFT",
193
+ "DNAmPhenoAgeNonPRC",
194
+ "DNAmPhenoAgePRC",
195
+ "IntrinClock370",
196
+ "MiAge",
197
+ "OrganAge",
198
+ "PCBrainAge",
199
+ "PCGrimAgeADM",
200
+ "PCGrimAgeB2M",
201
+ "PCGrimAgeCystatinC",
202
+ "PCGrimAgeGDF15",
203
+ "PCGrimAgeLeptin",
204
+ "PCGrimAgePackYrs",
205
+ "PCGrimAgePAI1",
206
+ "PCGrimAgeTIMP1",
178
207
  "ABEC",
179
208
  "ADBahadoSingh",
180
209
  "AltumAge",
@@ -0,0 +1,28 @@
1
+ """Author-defined IntrinClock and PhenoAge variants."""
2
+
3
+ import torch
4
+
5
+ from ._base_models import pyagingModel
6
+ from ._models import IntrinClock
7
+
8
+
9
+ class IntrinClock370(IntrinClock):
10
+ """The original cv.glmnet model at lambda.1se, as used by the author demo."""
11
+
12
+
13
+ class DNAmPhenoAgePRC(pyagingModel):
14
+ """Polycomb-related PhenoAge contribution, with no age intercept.
15
+
16
+ Matches the author's ``calcPRCPhenoAge(..., imputation = FALSE)``:
17
+ missing CpGs and supplied NA beta values make zero contribution.
18
+ """
19
+
20
+ def preprocess(self, x):
21
+ return torch.where(torch.isnan(x), torch.zeros_like(x), x)
22
+
23
+ def postprocess(self, x):
24
+ return x
25
+
26
+
27
+ class DNAmPhenoAgeNonPRC(DNAmPhenoAgePRC):
28
+ """Complementary non-polycomb PhenoAge contribution, without intercept."""
@@ -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
 
@@ -0,0 +1,33 @@
1
+ """Goeminne et al. plasma-protein OrganAge models.
2
+
3
+ Original author code and coefficients:
4
+ https://github.com/ludgergoeminne/organAging
5
+ DOI: 10.1016/j.cmet.2024.10.005
6
+
7
+ The released coefficients are subject to the authors' Academic / Non-Commercial
8
+ License. Each converted artifact carries that license and its pinned provenance.
9
+ """
10
+
11
+ from ._base_models import pyagingModel
12
+
13
+
14
+ class OrganAge(pyagingModel):
15
+ """Score Olink NPX values with a released OrganAge coefficient vector.
16
+
17
+ Protein names are the authors' case-sensitive symbols, including hyphens.
18
+ NPX values are already normalized; no cohort scaling is applied here. The
19
+ authors recommend fold one for new data. Chronological models include an
20
+ intercept and return years; mortality models have no intercept and return
21
+ relative natural-log mortality hazards.
22
+
23
+ The authors' example omits absent protein columns and propagates NaN values
24
+ in present columns. Zero-filled absent features preserve that omission in
25
+ pyaging, while present NaNs remain NaNs. Any imputation performed before
26
+ scoring is the caller's separate data-preparation decision.
27
+ """
28
+
29
+ def preprocess(self, x):
30
+ return x
31
+
32
+ def postprocess(self, x):
33
+ return x