pyaging 0.5.0__tar.gz → 0.5.2__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 (29) hide show
  1. {pyaging-0.5.0 → pyaging-0.5.2}/.gitignore +11 -0
  2. {pyaging-0.5.0 → pyaging-0.5.2}/PKG-INFO +1 -1
  3. {pyaging-0.5.0 → pyaging-0.5.2}/pyproject.toml +1 -1
  4. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/__init__.py +1 -1
  5. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/feature_ranges.json +1 -0
  6. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/__init__.py +8 -8
  7. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/_base_models.py +13 -0
  8. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/_models.py +103 -116
  9. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/__init__.py +1 -17
  10. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_pred.py +44 -2
  11. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_pred_utils.py +114 -5
  12. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_transforms.py +29 -0
  13. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/__init__.py +2 -0
  14. pyaging-0.5.2/src/pyaging/preprocess/_tage.py +366 -0
  15. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/_feature_ranges.py +1 -1
  16. pyaging-0.5.0/src/pyaging/predict/_inverse_transforms.py +0 -101
  17. {pyaging-0.5.0 → pyaging-0.5.2}/LICENSE +0 -0
  18. {pyaging-0.5.0 → pyaging-0.5.2}/README.md +0 -0
  19. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/__init__.py +0 -0
  20. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/_data.py +0 -0
  21. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/__init__.py +0 -0
  22. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/_live.py +0 -0
  23. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/_logger.py +0 -0
  24. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/_preprocess.py +0 -0
  25. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/_preprocess_utils.py +0 -0
  26. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/py.typed +0 -0
  27. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/__init__.py +0 -0
  28. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/_hf.py +0 -0
  29. {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/_utils.py +0 -0
@@ -36,6 +36,8 @@ example_data/
36
36
  # Generated clock weights and aggregate metadata (published to Hugging Face)
37
37
  clocks/weights/
38
38
  clocks/metadata/all_clock_metadata.pt
39
+ clocks/blood_chemistry_example.pkl
40
+ clocks/blood_chemistry_example.csv
39
41
 
40
42
  # Source datasets downloaded during clock-notebook execution; only the
41
43
  # notebooks themselves are tracked
@@ -60,3 +62,12 @@ docs/source/clock_notebooks/
60
62
  # Local scratch
61
63
  tmp/
62
64
  /data/
65
+
66
+ # Uncompressed tAge fixtures: regenerating writes ~50 MB of .csv into a tracked
67
+ # directory. Only the gzipped copies are committed.
68
+ tests/data/tage/*.csv
69
+ !tests/data/tage/input_metadata.csv
70
+
71
+ # Published tAge model pickles: regenerating the fixtures re-downloads them from
72
+ # Zenodo into the same directory. They are inputs, never committed artifacts.
73
+ tests/data/tage/*.pkl
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyaging
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -100,7 +100,7 @@ only-include = ["src/pyaging", "README.md", "LICENSE", "pyproject.toml"]
100
100
 
101
101
  [tool.ruff]
102
102
  line-length = 120
103
- extend-exclude = ["*.ipynb", "dev"]
103
+ extend-exclude = ["*.ipynb", "dev", "docs/superpowers"]
104
104
 
105
105
  [tool.ruff.lint]
106
106
  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.0"
7
+ __version__ = "0.5.2"
@@ -3,6 +3,7 @@
3
3
  "modality_defaults": {
4
4
  "DNA methylation": {"unit": "beta value", "low": 0.0, "high": 1.0},
5
5
  "transcriptomics": {"unit": null, "low": 0.0, "high": null},
6
+ "transcriptomics (relative)": {"unit": null, "low": null, "high": null},
6
7
  "histone modification": {"unit": null, "low": 0.0, "high": null},
7
8
  "chromatin accessibility": {"unit": null, "low": 0.0, "high": null}
8
9
  },
@@ -34,10 +34,8 @@ from ._models import (
34
34
  DepressionBarbu,
35
35
  DNAmFILi,
36
36
  DNAmFitAge,
37
- DNAmFitAgeGaitF,
38
- DNAmFitAgeGaitM,
39
- DNAmFitAgeGripF,
40
- DNAmFitAgeGripM,
37
+ DNAmFitAgeGait,
38
+ DNAmFitAgeGrip,
41
39
  DNAmFitAgeVO2Max,
42
40
  DNAmIC,
43
41
  DNAmPhenoAge,
@@ -154,6 +152,8 @@ from ._models import (
154
152
  SystemsAgeLung,
155
153
  SystemsAgeMetabolic,
156
154
  SystemsAgeMusculoSkeletal,
155
+ TAge,
156
+ TAgeMortality,
157
157
  Thompson,
158
158
  TwelveCellDeconvoluteBloodEPIC,
159
159
  VidalBralo,
@@ -199,10 +199,8 @@ __all__ = [
199
199
  "CpGPTPCGrimAge3",
200
200
  "DNAmFILi",
201
201
  "DNAmFitAge",
202
- "DNAmFitAgeGaitF",
203
- "DNAmFitAgeGaitM",
204
- "DNAmFitAgeGripF",
205
- "DNAmFitAgeGripM",
202
+ "DNAmFitAgeGait",
203
+ "DNAmFitAgeGrip",
206
204
  "DNAmFitAgeVO2Max",
207
205
  "DNAmIC",
208
206
  "DNAmPhenoAge",
@@ -325,6 +323,8 @@ __all__ = [
325
323
  "SystemsAgeLung",
326
324
  "SystemsAgeMetabolic",
327
325
  "SystemsAgeMusculoSkeletal",
326
+ "TAge",
327
+ "TAgeMortality",
328
328
  "Thompson",
329
329
  "TwelveCellDeconvoluteBloodEPIC",
330
330
  "VidalBralo",
@@ -24,6 +24,15 @@ class pyagingModel(nn.Module, ABC):
24
24
 
25
25
  self.reference_values = None
26
26
 
27
+ # Name of an ``adata.uns`` key that must be truthy before this clock may
28
+ # run, for a clock whose input contract nothing can satisfy for it.
29
+ self.required_uns_flag = None
30
+
31
+ # Name of a cohort transform ``predict_age`` runs before aligning this
32
+ # clock's features; cohort-relative clocks set it so their whole-cohort
33
+ # preprocessing happens automatically, and it supersedes the flag above.
34
+ self.cohort_transform = None
35
+
27
36
  self.preprocess_name = None
28
37
  self.preprocess_dependencies = None
29
38
 
@@ -32,6 +41,10 @@ class pyagingModel(nn.Module, ABC):
32
41
 
33
42
  self.features = None
34
43
  self.feature_units = None
44
+ # Optional internal range profile when the model consumes values that
45
+ # differ from the public assay modality (for example, cohort-centered
46
+ # transcriptomic values derived from raw RNA-seq counts).
47
+ self.feature_range_data_type = None
35
48
  self.base_model_features = self.features
36
49
 
37
50
  self.base_model = None
@@ -7,8 +7,7 @@ import torch.nn as nn
7
7
  # It holds because ``_pred_utils`` only takes names ``models/__init__`` has already bound
8
8
  # from ``_base_models`` by the time this module runs; importing a name defined here would
9
9
  # break it.
10
- from ..predict._inverse_transforms import mortality_to_phenoage_saopaulo
11
- from ..predict._transforms import PHENOAGE_CRP_FLOOR_MG_DL, crp_index, log1p_crp
10
+ from ..predict._transforms import PHENOAGE_CRP_FLOOR_MG_DL, crp_index, log1p_crp, mortality_to_phenoage_saopaulo
12
11
  from ._base_models import pyagingModel
13
12
 
14
13
  # LinAge2's ``foldOutliers`` cap, in MAD-scaled z units, applied to every feature.
@@ -2117,66 +2116,50 @@ class DNAmFitAge(pyagingModel):
2117
2116
  def __init__(self):
2118
2117
  super().__init__()
2119
2118
 
2120
- self.GaitF = None
2121
- self.GripF = None
2122
- self.GaitM = None
2123
- self.GripM = None
2119
+ self.Gait = None
2120
+ self.Grip = None
2124
2121
  self.VO2Max = None
2122
+ self.GrimAge = None
2125
2123
 
2126
- self.features_GaitF = None
2127
- self.features_GripF = None
2128
- self.features_GaitM = None
2129
- self.features_GripM = None
2124
+ self.features_Gait = None
2125
+ self.features_Grip = None
2130
2126
  self.features_VO2Max = None
2127
+ self.features_GrimAge = None
2128
+ self.female_index = None
2129
+ self.age_index = None
2131
2130
 
2132
- def forward(self, x):
2133
- Female = x[:, -3] # .unsqueeze(1)
2134
- GrimAge = x[:, -1].unsqueeze(1)
2135
-
2136
- is_female = Female == 1
2137
- is_male = Female == 0
2138
-
2139
- x_f = x[is_female]
2140
- x_m = x[is_male]
2141
-
2142
- GaitF = self.GaitF(x_f[:, self.features_GaitF])
2143
- GripF = self.GripF(x_f[:, self.features_GripF])
2144
- VO2MaxF = self.VO2Max(x_f[:, self.features_VO2Max])
2145
- GrimAgeF = GrimAge[is_female, :]
2131
+ def _component_input(self, x, indices, component):
2132
+ values = x[:, indices]
2133
+ return _fill_dnamfitage_references(values, component.reference_values)
2146
2134
 
2147
- GaitM = self.GaitM(x_m[:, self.features_GaitM])
2148
- GripM = self.GripM(x_m[:, self.features_GripM])
2149
- VO2MaxM = self.VO2Max(x_m[:, self.features_VO2Max])
2150
- GrimAgeM = GrimAge[is_male, :]
2135
+ def forward(self, x):
2136
+ female = x[:, self.female_index].unsqueeze(1)
2137
+ gait = self.Gait(self._component_input(x, self.features_Gait, self.Gait))
2138
+ grip = self.Grip(self._component_input(x, self.features_Grip, self.Grip))
2139
+ vo2max = self.VO2Max(self._component_input(x, self.features_VO2Max, self.VO2Max))
2140
+ grimage = self.GrimAge(self._component_input(x, self.features_GrimAge, self.GrimAge))
2151
2141
 
2152
- x_f = torch.concat(
2142
+ female_components = torch.concat(
2153
2143
  [
2154
- (VO2MaxF - 46.825091) / (-0.13620215),
2155
- (GripF - 39.857718) / (-0.22074456),
2156
- (GaitF - 2.508547) / (-0.01245682),
2157
- (GrimAgeF - 7.978487) / (0.80928530),
2144
+ (vo2max - 46.825091) / -0.13620215,
2145
+ (grip - 39.857718) / -0.22074456,
2146
+ (gait - 2.508547) / -0.01245682,
2147
+ (grimage - 7.978487) / 0.80928530,
2158
2148
  ],
2159
2149
  dim=1,
2160
2150
  )
2161
-
2162
- x_m = torch.concat(
2151
+ male_components = torch.concat(
2163
2152
  [
2164
- (VO2MaxM - 49.836389) / (-0.141862925),
2165
- (GripM - 57.514016) / (-0.253179827),
2166
- (GaitM - 2.349080) / (-0.009380061),
2167
- (GrimAgeM - 9.549733) / (0.835120557),
2153
+ (vo2max - 49.836389) / -0.141862925,
2154
+ (grip - 57.514016) / -0.253179827,
2155
+ (gait - 2.349080) / -0.009380061,
2156
+ (grimage - 9.549733) / 0.835120557,
2168
2157
  ],
2169
2158
  dim=1,
2170
2159
  )
2171
-
2172
- y_f = self.base_model_f(x_f)
2173
- y_m = self.base_model_m(x_m)
2174
-
2175
- y = torch.zeros((x.size(0), 1), dtype=x.dtype, device=x.device)
2176
- y[is_female] = y_f
2177
- y[is_male] = y_m
2178
-
2179
- return y
2160
+ female_prediction = self.base_model_f(female_components)
2161
+ male_prediction = self.base_model_m(male_components)
2162
+ return male_prediction + female * (female_prediction - male_prediction)
2180
2163
 
2181
2164
  def preprocess(self, x):
2182
2165
  if self.reference_values is None:
@@ -2191,6 +2174,45 @@ class DNAmFitAge(pyagingModel):
2191
2174
  return x
2192
2175
 
2193
2176
 
2177
+ def _fill_dnamfitage_references(x, reference_values):
2178
+ reference = torch.as_tensor(reference_values, device=x.device, dtype=x.dtype)
2179
+ return torch.where(torch.isnan(x), reference, x)
2180
+
2181
+
2182
+ class _DNAmFitAgeSexGated(pyagingModel):
2183
+ def __init__(self):
2184
+ super().__init__()
2185
+ self.female_model = None
2186
+ self.male_model = None
2187
+ self.female_feature_indices = None
2188
+ self.male_feature_indices = None
2189
+ self.female_reference_values = None
2190
+ self.male_reference_values = None
2191
+ self.female_index = None
2192
+
2193
+ def forward(self, x):
2194
+ female = x[:, self.female_index].unsqueeze(1)
2195
+ female_x = _fill_dnamfitage_references(x[:, self.female_feature_indices], self.female_reference_values)
2196
+ male_x = _fill_dnamfitage_references(x[:, self.male_feature_indices], self.male_reference_values)
2197
+ female_prediction = self.female_model(female_x)
2198
+ male_prediction = self.male_model(male_x)
2199
+ return male_prediction + female * (female_prediction - male_prediction)
2200
+
2201
+ def preprocess(self, x):
2202
+ return x
2203
+
2204
+ def postprocess(self, x):
2205
+ return x
2206
+
2207
+
2208
+ class DNAmFitAgeGait(_DNAmFitAgeSexGated):
2209
+ pass
2210
+
2211
+
2212
+ class DNAmFitAgeGrip(_DNAmFitAgeSexGated):
2213
+ pass
2214
+
2215
+
2194
2216
  class StocH(pyagingModel):
2195
2217
  def __init__(self):
2196
2218
  super().__init__()
@@ -2681,74 +2703,6 @@ class GrimAge2LogCRP(pyagingModel):
2681
2703
  return x
2682
2704
 
2683
2705
 
2684
- class DNAmFitAgeGaitF(pyagingModel):
2685
- def __init__(self):
2686
- super().__init__()
2687
-
2688
- def preprocess(self, x):
2689
- if self.reference_values is None:
2690
- return x
2691
- if isinstance(self.reference_values, torch.Tensor):
2692
- reference = self.reference_values.to(device=x.device, dtype=x.dtype)
2693
- else:
2694
- reference = torch.tensor(self.reference_values, device=x.device, dtype=x.dtype)
2695
- return torch.where(torch.isnan(x), reference, x)
2696
-
2697
- def postprocess(self, x):
2698
- return x
2699
-
2700
-
2701
- class DNAmFitAgeGaitM(pyagingModel):
2702
- def __init__(self):
2703
- super().__init__()
2704
-
2705
- def preprocess(self, x):
2706
- if self.reference_values is None:
2707
- return x
2708
- if isinstance(self.reference_values, torch.Tensor):
2709
- reference = self.reference_values.to(device=x.device, dtype=x.dtype)
2710
- else:
2711
- reference = torch.tensor(self.reference_values, device=x.device, dtype=x.dtype)
2712
- return torch.where(torch.isnan(x), reference, x)
2713
-
2714
- def postprocess(self, x):
2715
- return x
2716
-
2717
-
2718
- class DNAmFitAgeGripF(pyagingModel):
2719
- def __init__(self):
2720
- super().__init__()
2721
-
2722
- def preprocess(self, x):
2723
- if self.reference_values is None:
2724
- return x
2725
- if isinstance(self.reference_values, torch.Tensor):
2726
- reference = self.reference_values.to(device=x.device, dtype=x.dtype)
2727
- else:
2728
- reference = torch.tensor(self.reference_values, device=x.device, dtype=x.dtype)
2729
- return torch.where(torch.isnan(x), reference, x)
2730
-
2731
- def postprocess(self, x):
2732
- return x
2733
-
2734
-
2735
- class DNAmFitAgeGripM(pyagingModel):
2736
- def __init__(self):
2737
- super().__init__()
2738
-
2739
- def preprocess(self, x):
2740
- if self.reference_values is None:
2741
- return x
2742
- if isinstance(self.reference_values, torch.Tensor):
2743
- reference = self.reference_values.to(device=x.device, dtype=x.dtype)
2744
- else:
2745
- reference = torch.tensor(self.reference_values, device=x.device, dtype=x.dtype)
2746
- return torch.where(torch.isnan(x), reference, x)
2747
-
2748
- def postprocess(self, x):
2749
- return x
2750
-
2751
-
2752
2706
  class DNAmFitAgeVO2Max(pyagingModel):
2753
2707
  def __init__(self):
2754
2708
  super().__init__()
@@ -3271,3 +3225,36 @@ class EpiCMITHypo(epiTOC1):
3271
3225
 
3272
3226
  class EpiTOC3(epiTOC2):
3273
3227
  pass
3228
+
3229
+
3230
+ class TAge(pyagingModel):
3231
+ def __init__(self):
3232
+ super().__init__()
3233
+ # Cohort-relative clock: ``predict_age`` runs the tAge cohort
3234
+ # preprocessing on the raw counts before aligning these features.
3235
+ self.cohort_transform = "tage"
3236
+ self.feature_range_data_type = "transcriptomics (relative)"
3237
+
3238
+ def preprocess(self, x):
3239
+ """Substitute the fitted imputer statistics for genes absent from the sample.
3240
+
3241
+ ``reference_values`` holds the published pipeline's ``SimpleImputer`` fill
3242
+ values, so a gene the aligned list carries but the sample does not gets the
3243
+ exact value the model was fitted to expect. For a cohort-centered clock that
3244
+ is the least-biased choice available: the gene contributes its training median
3245
+ rather than pulling the score toward zero or toward this cohort's center.
3246
+ """
3247
+ if self.reference_values is None:
3248
+ return x
3249
+ if isinstance(self.reference_values, torch.Tensor):
3250
+ reference = self.reference_values.to(device=x.device, dtype=x.dtype)
3251
+ else:
3252
+ reference = torch.tensor(self.reference_values, device=x.device, dtype=x.dtype)
3253
+ return torch.where(torch.isnan(x), reference, x)
3254
+
3255
+ def postprocess(self, x):
3256
+ return x
3257
+
3258
+
3259
+ class TAgeMortality(TAge):
3260
+ """Mortality head of the same pipeline family, with the same cohort-relative input contract."""
@@ -1,15 +1,5 @@
1
1
  # pyaging/predict/__init__.py
2
2
 
3
- from ._inverse_transforms import (
4
- anti_log,
5
- anti_log_linear,
6
- anti_log_log,
7
- anti_logp2,
8
- mortality_to_phenoage,
9
- mortality_to_phenoage_saopaulo,
10
- petkovichblood,
11
- stubbsmultitissue,
12
- )
13
3
  from ._pred import predict_age
14
4
  from ._pred_utils import (
15
5
  add_pred_ages_and_clock_metadata_adata,
@@ -22,6 +12,7 @@ from ._pred_utils import (
22
12
  )
23
13
  from ._transforms import (
24
14
  binarize,
15
+ mortality_to_phenoage_saopaulo,
25
16
  quantile_normalize_with_gold_standard,
26
17
  scale,
27
18
  scale_row,
@@ -31,18 +22,12 @@ from ._transforms import (
31
22
 
32
23
  __all__ = [
33
24
  "add_pred_ages_and_clock_metadata_adata",
34
- "anti_log",
35
- "anti_log_linear",
36
- "anti_log_log",
37
- "anti_logp2",
38
25
  "binarize",
39
26
  "check_feature_ranges",
40
27
  "check_features_in_adata",
41
28
  "cleanup_clock_memory",
42
29
  "load_clock",
43
- "mortality_to_phenoage",
44
30
  "mortality_to_phenoage_saopaulo",
45
- "petkovichblood",
46
31
  "predict_age",
47
32
  "predict_ages_with_model",
48
33
  "quantile_normalize_with_gold_standard",
@@ -50,6 +35,5 @@ __all__ = [
50
35
  "scale_row",
51
36
  "scale_with_gold_standard",
52
37
  "set_torch_device",
53
- "stubbsmultitissue",
54
38
  "tpm_norm_log1p",
55
39
  ]
@@ -6,6 +6,8 @@ import torch
6
6
  from ..logger._live import ClockRunDisplay, DisplayLogger, display_enabled, quiet_hf_bars
7
7
  from ._pred_utils import (
8
8
  add_pred_ages_and_clock_metadata_adata,
9
+ apply_cohort_transform,
10
+ build_cohort_feature_matrix,
9
11
  check_feature_ranges,
10
12
  check_features_in_adata,
11
13
  load_clock,
@@ -70,6 +72,21 @@ def predict_age(
70
72
  It is important that the input AnnData object's .X attribute contains data suitable for age
71
73
  prediction.
72
74
 
75
+ A few clocks are cohort-relative: they read a sample only in the context of the samples
76
+ predicted alongside it, so their preprocessing is computed here, over the whole input, before
77
+ any batching. The tAge clocks (`tage`, `tagemortality`) work this way. Pass them raw RNA-seq
78
+ counts and at least two samples; .X itself is never modified, and what the preprocessing did is
79
+ recorded in .uns["tage_preparation"]. Two optional columns steer it:
80
+
81
+ - Species: a column named `mouse`, `rat`, `macaque`, or `human` among .var_names, set to 1 for
82
+ every sample, the same idiom the mammalian clocks use for covariates such as `female`. Only
83
+ one may be set, and it is dropped before the gene pipeline rather than read as a gene. With
84
+ no such column — or with the columns present but zero everywhere — the cohort is taken to be
85
+ mouse and a warning says so, on the display and as a UserWarning.
86
+ - Reference group: .obs["tage_reference_group"], boolean or numeric 0/1, whose truthy rows are
87
+ the samples to centre against. Without it the cohort centres on every sample. Predictions are
88
+ differences against that reference, not absolute values.
89
+
73
90
  The function automatically handles the transfer of data and models to the appropriate compute
74
91
  device (CPU or GPU) based on system configuration.
75
92
 
@@ -90,6 +107,10 @@ def predict_age(
90
107
 
91
108
  enabled = display_enabled(verbose)
92
109
  display = ClockRunDisplay(clock_names, str(device), enabled=enabled)
110
+ # Whole-cohort preprocessing, keyed by transform name and computed at most
111
+ # once per call: tage and tagemortality read the same transformed frame, and
112
+ # recomputing it per clock would be both slow and pointless.
113
+ cohort_frames = {}
93
114
  with quiet_hf_bars(verbose), display:
94
115
  for clock_name in clock_names:
95
116
  display.start_clock(clock_name, "loading weights")
@@ -99,13 +120,34 @@ def predict_age(
99
120
  # Load and prepare the clock
100
121
  model = load_clock(clock_name, device, dir, pipeline_logger)
101
122
 
123
+ # Clocks saved before either attribute existed lack both.
124
+ transform_name = getattr(model, "cohort_transform", None)
125
+ required_flag = getattr(model, "required_uns_flag", None)
126
+ # A clock whose input contract nothing can satisfy for it refuses to
127
+ # run unmarked input. A declared transform supersedes the flag: it
128
+ # produces exactly what the flag was there to demand, and weights
129
+ # built before the transform existed still carry the flag.
130
+ if transform_name is None and required_flag is not None and not adata.uns.get(required_flag, False):
131
+ raise ValueError(
132
+ f"Clock '{clock_name}' needs preprocessed input (adata.uns['{required_flag}'] is missing)."
133
+ )
134
+
102
135
  # Disclaimer for commercial clocks
103
136
  if model.metadata.get("research_only", False):
104
137
  display.warn(clock_name, "research use only")
105
138
 
106
139
  # Check and update adata for missing features
107
- display.stage(clock_name, "matching features")
108
- check_features_in_adata(adata, model, pipeline_logger)
140
+ if transform_name is not None:
141
+ # Cohort-relative clock: its features live in the space the
142
+ # transform produces, not in adata.X.
143
+ if transform_name not in cohort_frames:
144
+ display.stage(clock_name, "preprocessing the cohort")
145
+ cohort_frames[transform_name] = apply_cohort_transform(adata, transform_name, dir, pipeline_logger)
146
+ display.stage(clock_name, "matching features")
147
+ build_cohort_feature_matrix(adata, model, cohort_frames[transform_name], pipeline_logger)
148
+ else:
149
+ display.stage(clock_name, "matching features")
150
+ check_features_in_adata(adata, model, pipeline_logger)
109
151
 
110
152
  # Warn if the input values look implausible for these features
111
153
  display.stage(clock_name, "checking feature ranges")
@@ -14,10 +14,38 @@ except Exception:
14
14
  import gc
15
15
 
16
16
  from ..models import pyagingModel
17
+ from ..preprocess._tage import _prepare_tage
17
18
  from ..utils._feature_ranges import resolve_feature_bounds
18
19
  from ..utils._hf import PyAgingResourceNotFoundError, download_clock_weights
19
20
  from ..utils._utils import progress
20
21
 
22
+ # Whole-cohort preprocessing a clock can ask ``predict_age`` to run for it, by
23
+ # name. A transform takes the raw AnnData and returns a samples x features
24
+ # frame; it must not write to ``adata.X``, since the other clocks in the same
25
+ # call still read the original matrix.
26
+ COHORT_TRANSFORMS = {"tage": _prepare_tage}
27
+
28
+
29
+ @progress("Run the cohort transform")
30
+ def apply_cohort_transform(
31
+ adata: anndata.AnnData,
32
+ transform_name: str,
33
+ dir: str,
34
+ logger,
35
+ indent_level: int = 2,
36
+ ):
37
+ """Run the named cohort transform over the whole input and return its frame."""
38
+ try:
39
+ transform = COHORT_TRANSFORMS[transform_name]
40
+ except KeyError:
41
+ message = (
42
+ f"This clock asks for a cohort transform named {transform_name!r}, which this version of "
43
+ f"pyaging does not provide. Known transforms: {sorted(COHORT_TRANSFORMS)}."
44
+ )
45
+ logger.error(message, indent_level=indent_level + 1)
46
+ raise ValueError(message) from None
47
+ return transform(adata, dir=dir, logger=logger)
48
+
21
49
 
22
50
  def load_clock(
23
51
  clock_name: str,
@@ -94,8 +122,12 @@ def _load_clock_impl(clock_name: str, device: str, dir: str, logger, indent_leve
94
122
  try:
95
123
  weights_path = download_clock_weights(clock_name, dir, logger, indent_level=indent_level)
96
124
  except PyAgingResourceNotFoundError as exc:
125
+ from pyaging import __version__
126
+
97
127
  message = (
98
- f"Clock {clock_name} is not available on pyaging. "
128
+ f"Clock {clock_name} is not available on pyaging {__version__}. "
129
+ "This clock may require a newer pyaging release; check PyPI and run "
130
+ "`pip install --upgrade pyaging` if a newer version is available. "
99
131
  "Please refer to the clock names in the clock glossary table "
100
132
  "in the package documentation page: pyaging.readthedocs.io"
101
133
  )
@@ -173,6 +205,81 @@ def check_features_in_adata(
173
205
  >>> updated_adata.var_names
174
206
  Index(['gene1', 'gene2', ...], dtype='object')
175
207
 
208
+ """
209
+ _align_features_into_obsm(adata, model, adata.X, adata.var_names, logger, indent_level)
210
+
211
+
212
+ @progress("Build the cohort feature matrix")
213
+ def build_cohort_feature_matrix(
214
+ adata: anndata.AnnData,
215
+ model: pyagingModel,
216
+ frame,
217
+ logger,
218
+ indent_level: int = 2,
219
+ ) -> None:
220
+ """
221
+ Assemble a cohort-relative clock's feature matrix from its transformed frame.
222
+
223
+ A clock declaring ``cohort_transform`` is not scored against ``adata.X``: its
224
+ features live in the space the transform produces (for tAge, cohort-centred
225
+ mouse Entrez columns), so alignment reads that frame instead. Everything else
226
+ -- reference-value substitution for features the transform did not yield, the
227
+ missing-feature bookkeeping, and the supplied-features mask that
228
+ :func:`check_feature_ranges` reads -- is identical to
229
+ :func:`check_features_in_adata`, which is why both share one implementation.
230
+
231
+ Parameters
232
+ ----------
233
+ adata : anndata.AnnData
234
+ The object the matrix and the bookkeeping are written to. Its ``.X`` is
235
+ only read by the transform, never here.
236
+
237
+ model : pyagingModel
238
+ The clock being aligned.
239
+
240
+ frame : pandas.DataFrame
241
+ Samples x transformed-features, in the row order of ``adata``.
242
+
243
+ logger : Logger
244
+ A logger object used for logging information about the process.
245
+
246
+ indent_level : int, optional
247
+ The indentation level for the logger, by default 2.
248
+
249
+ Returns
250
+ -------
251
+ None
252
+ The AnnData object is updated in place.
253
+
254
+ Raises
255
+ ------
256
+ ValueError
257
+ If the frame's rows are not the cohort's samples. The matrix is filled
258
+ positionally, so a frame holding different samples -- or the same ones
259
+ under duplicated labels -- would attach one sample's expression to
260
+ another's predicted age, which is worse than not predicting at all. A
261
+ frame that merely orders the same samples differently is reordered to
262
+ match rather than rejected.
263
+ """
264
+ if not frame.index.equals(adata.obs_names):
265
+ if frame.index.has_duplicates or set(frame.index) != set(adata.obs_names):
266
+ message = (
267
+ f"The cohort transform returned {len(frame.index)} rows that are not this cohort's "
268
+ f"{adata.n_obs} samples, so its values cannot be matched to them."
269
+ )
270
+ logger.error(message, indent_level=indent_level + 1)
271
+ raise ValueError(message)
272
+ frame = frame.reindex(adata.obs_names)
273
+
274
+ _align_features_into_obsm(adata, model, frame.to_numpy(), frame.columns, logger, indent_level)
275
+
276
+
277
+ def _align_features_into_obsm(adata, model, source_values, source_features, logger, indent_level: int) -> None:
278
+ """Fill ``obsm["X_{clock}"]`` from ``source_values``, one column per model feature.
279
+
280
+ Features the source does not carry take the model's reference value (or 0
281
+ when it has none), and the substitutions are recorded so downstream checks
282
+ can tell the input's own values apart from the pipeline's.
176
283
  """
177
284
 
178
285
  # Preallocate the data matrix
@@ -182,8 +289,8 @@ def check_features_in_adata(
182
289
  else np.empty((adata.n_obs, len(model.features)), order="F")
183
290
  )
184
291
 
185
- # Find indices of matching features in adata.var_names
186
- feature_indices = {feature: i for i, feature in enumerate(adata.var_names)}
292
+ # Find indices of matching features among the source's own feature names
293
+ feature_indices = {feature: i for i, feature in enumerate(source_features)}
187
294
  model_feature_indices = np.array([feature_indices.get(feature, -1) for feature in model.features])
188
295
 
189
296
  # Identify missing features
@@ -193,7 +300,9 @@ def check_features_in_adata(
193
300
  # Assign values for existing features
194
301
  existing_features_mask = ~missing_features_mask
195
302
  existing_features_indices = model_feature_indices[existing_features_mask]
196
- adata.obsm[f"X_{model.metadata['clock_name']}"][:, existing_features_mask] = adata.X[:, existing_features_indices]
303
+ adata.obsm[f"X_{model.metadata['clock_name']}"][:, existing_features_mask] = source_values[
304
+ :, existing_features_indices
305
+ ]
197
306
 
198
307
  # Handle missing features
199
308
  adata.obsm[f"X_{model.metadata['clock_name']}"][:, missing_features_mask] = (
@@ -347,7 +456,7 @@ def check_feature_ranges(
347
456
  try:
348
457
  units, low, high = resolve_feature_bounds(
349
458
  model.features,
350
- model.metadata.get("data_type"),
459
+ getattr(model, "feature_range_data_type", None) or model.metadata.get("data_type"),
351
460
  getattr(model, "feature_units", None),
352
461
  )
353
462
  except Exception as exc:
@@ -99,6 +99,35 @@ def quantile_normalize_with_gold_standard(x, gold_standard_means):
99
99
  return x_normalized
100
100
 
101
101
 
102
+ def mortality_to_phenoage_saopaulo(x, m_n, m_d, ba_n, ba_d, ba_i):
103
+ """
104
+ Applies a convertion from a CDF of the mortality score from a Gompertz
105
+ distribution to phenotypic age, using constants supplied by the caller.
106
+
107
+ Levine's 2018 PhenoAge hardcodes its published constants. A refit of that
108
+ model fits its own mortality-to-age constants alongside its coefficients,
109
+ so they are passed in rather than baked in.
110
+
111
+ Parameters
112
+ ----------
113
+ x : torch.Tensor
114
+ The linear predictor, one value per sample.
115
+
116
+ m_n, m_d : torch.Tensor
117
+ Numerator and denominator of the Gompertz mortality exponent.
118
+
119
+ ba_n, ba_d, ba_i : torch.Tensor
120
+ Scale, rate, and intercept converting mortality back to years.
121
+
122
+ Returns
123
+ -------
124
+ torch.Tensor
125
+ Phenotypic age in years.
126
+ """
127
+ mortality_score = 1 - torch.exp(m_n * torch.exp(x) / m_d)
128
+ return torch.log(ba_n * torch.log(1 - mortality_score)) / ba_d + ba_i
129
+
130
+
102
131
  # Lowest C-reactive protein ``PhenoAge`` will take the natural log of, in mg/dL. Its
103
132
  # clamp is the only CRP transform that needs a floor, and the value matches the registry
104
133
  # floor in data/feature_ranges.json so that anything the clamp touches has already been
@@ -9,8 +9,10 @@ from ._preprocess_utils import (
9
9
  load_ensembl_metadata,
10
10
  log_data_statistics,
11
11
  )
12
+ from ._tage import TAGE_SPECIES
12
13
 
13
14
  __all__ = [
15
+ "TAGE_SPECIES",
14
16
  "add_metadata_to_anndata",
15
17
  "add_unstructured_data",
16
18
  "bigwig_to_df",
@@ -0,0 +1,366 @@
1
+ """Cohort preprocessing for the tAge transcriptomic clocks (Tyshkovskiy 2026).
2
+
3
+ The tAge models live in mouse Entrez gene space, so expression from any of the
4
+ four supported species has to be translated before anything else happens.
5
+ ``_map_to_mouse_entrez`` mirrors ``map_genes`` in the reference package's
6
+ ``R/genes.R``: an unrecognised gene is dropped, and several source genes landing
7
+ on the same mouse Entrez ID have their values **summed** into one column
8
+ (``tapply(x, mapped_ids, sum)`` in ``.apply_gene_mapping``). Summing is correct
9
+ here because mapping happens on raw counts, before normalisation.
10
+
11
+ The reference resolves IDs in two hops (source ID -> source-species Entrez ->
12
+ mouse Entrez, the second hop skipped for mouse and macaque) and, unlike the
13
+ first hop, breaks ties in the second by keeping the first row rather than
14
+ summing. The mapping asset built by ``clocks/build_tage_gene_mapping.py``
15
+ flattens both hops into a single lookup, which is exact rather than an
16
+ approximation only because every ortholog hop is injective on the reference
17
+ tables; the build script asserts that invariant.
18
+ """
19
+
20
+ import warnings
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from ..utils._hf import download_hf_file
26
+
27
+ TAGE_SPECIES = ("mouse", "rat", "macaque", "human")
28
+
29
+ # ``var_names`` that carry the cohort's species rather than a gene, one 0/1
30
+ # value per sample. The clocks reuse the mammalian-array idiom, where a
31
+ # covariate such as ``female`` is simply another column of the matrix.
32
+ SPECIES_COLUMNS = TAGE_SPECIES
33
+
34
+ # ``obs`` column naming the samples to centre against.
35
+ REFERENCE_COLUMN = "tage_reference_group"
36
+
37
+ # filter_genes' defaults in the reference pipeline (R/preprocessing.R:68).
38
+ COUNT_THRESHOLD = 10
39
+ PERCENT_THRESHOLD = 20
40
+
41
+
42
+ def _warn(logger, message: str) -> None:
43
+ """Raise a warning on both the predict display and Python's warnings channel.
44
+
45
+ The display logger renders nothing at ``verbose=False``, so a warning that
46
+ only went there would be invisible in exactly the scripted runs where it
47
+ matters most -- notably the silent default to mouse, which can score a human
48
+ cohort as a mouse one. Both channels are fed from one string so their
49
+ wording cannot drift apart.
50
+
51
+ ``stacklevel=2`` blames the pipeline stage that called this, not this line.
52
+ Reaching the user's ``predict_age`` call would mean hard-coding the depth of
53
+ ``predict_age -> apply_cohort_transform -> _prepare_tage``, which is more
54
+ fragile than it is worth.
55
+ """
56
+ logger.warning(message, indent_level=2)
57
+ warnings.warn(message, UserWarning, stacklevel=2)
58
+
59
+
60
+ def _filter_genes(
61
+ df: pd.DataFrame, count_threshold: int = COUNT_THRESHOLD, percent_threshold: float = PERCENT_THRESHOLD
62
+ ) -> pd.DataFrame:
63
+ """Drop genes not expressed in enough samples, as ``filter_genes`` does.
64
+
65
+ The reference (``R/preprocessing.R:72``) keeps a gene when
66
+ ``sum(x >= count_threshold, na.rm = TRUE) >= ncol * (percent_threshold/100)``.
67
+ Both comparisons are inclusive, and ``na.rm`` means a missing count never
68
+ counts toward the quota -- which ``NaN >= threshold`` reproduces, since that
69
+ comparison is ``False``.
70
+
71
+ The filter is not cosmetic: on the authors' own example data it takes 57 010
72
+ genes down to 19 550, and running the mapper without it yields a different
73
+ gene set (28 266 columns) and different RLE factors downstream.
74
+ """
75
+ passing = (df >= count_threshold).sum(axis=0) >= df.shape[0] * (percent_threshold / 100)
76
+ return df.loc[:, passing]
77
+
78
+
79
+ def _map_to_mouse_entrez(df: pd.DataFrame, species: str, mapping: pd.DataFrame) -> pd.DataFrame:
80
+ """Translate a samples x genes frame into samples x mouse-Entrez columns.
81
+
82
+ Genes with no mapping are dropped; genes sharing a mouse Entrez ID are summed.
83
+ """
84
+ if species not in TAGE_SPECIES:
85
+ raise ValueError(f"species must be one of {TAGE_SPECIES}, got {species!r}")
86
+
87
+ rows = mapping[mapping["species"] == species]
88
+ lookup = dict(zip(rows["source_id"], rows["mouse_entrez"], strict=True))
89
+
90
+ # Entrez IDs read from a CSV arrive as integers, so normalise before lookup.
91
+ targets = df.columns.astype(str).str.upper().map(lookup)
92
+ kept = targets.notna()
93
+
94
+ mapped = df.loc[:, kept].copy()
95
+ mapped.columns = targets[kept]
96
+ return mapped.T.groupby(level=0).sum().T
97
+
98
+
99
+ def _rle_normalize(df: pd.DataFrame) -> pd.DataFrame:
100
+ """Scale raw counts by edgeR's RLE factors, as ``RLE_normalization`` does.
101
+
102
+ The reference (``R/preprocessing.R:111``) divides the counts by
103
+ ``lib.size * calcNormFactors(counts, method = "RLE")`` and multiplies by
104
+ 1e7, so the result is counts per 10 million RLE-adjusted library size.
105
+
106
+ edgeR's RLE factor for a sample is the median ratio of its counts to the
107
+ per-gene geometric mean over the cohort, taken over the genes whose
108
+ geometric mean is nonzero (``.calcFactorRLE``); ``calcNormFactors`` then
109
+ divides by the library size and rescales the factors to a geometric mean of
110
+ one. A gene that is zero in any sample has a zero geometric mean and so
111
+ drops out, which also covers edgeR's up-front removal of all-zero genes.
112
+ """
113
+ counts = df.to_numpy(dtype=float)
114
+ if counts.shape[1] == 0:
115
+ raise ValueError("RLE normalization needs at least one gene")
116
+ if np.isnan(counts).any():
117
+ # calcNormFactors stops on NA counts rather than propagating them.
118
+ raise ValueError("RLE normalization needs counts without missing values")
119
+
120
+ library_sizes = counts.sum(axis=1)
121
+ with np.errstate(divide="ignore"):
122
+ # log(0) -> -inf -> a zero geometric mean, which is what excludes the gene.
123
+ geometric_means = np.exp(np.log(counts).mean(axis=0))
124
+ usable = geometric_means > 0
125
+ if not usable.any():
126
+ raise ValueError("RLE normalization needs a gene expressed in every sample")
127
+
128
+ ratios = counts[:, usable] / geometric_means[usable]
129
+ factors = np.median(ratios, axis=1) / library_sizes
130
+ factors /= np.exp(np.log(factors).mean())
131
+
132
+ return df.div(library_sizes * factors, axis=0) * 1e7
133
+
134
+
135
+ def _log_transform(df: pd.DataFrame) -> pd.DataFrame:
136
+ """Apply ``log10(x + 1)``, matching ``log_transform`` (``R/preprocessing.R:218``)."""
137
+ return np.log10(df + 1.0)
138
+
139
+
140
+ def _scale_samples(df: pd.DataFrame) -> pd.DataFrame:
141
+ """Z-score each sample across its genes, as ``scale_eset`` does.
142
+
143
+ ``scale_eset`` (``R/preprocessing.R:254``) calls base R's ``scale()`` on a
144
+ genes x samples matrix, and ``scale()`` works down the columns -- so despite
145
+ the reference's name for the stage the standardisation is per sample, over
146
+ the gene axis, with the sample standard deviation (``ddof=1``). A sample with
147
+ no variation yields ``NaN`` here exactly as it does in R; nothing fills it in.
148
+ """
149
+ return df.sub(df.mean(axis=1), axis=0).div(df.std(axis=1, ddof=1), axis=0)
150
+
151
+
152
+ def _center_against_reference(df: pd.DataFrame, reference_index=None) -> pd.DataFrame:
153
+ """Subtract the reference group's per-gene median from every sample.
154
+
155
+ Mirrors ``control_subtraction`` (``R/preprocessing.R:298``): with no
156
+ reference group the median is taken over all samples, and the median skips
157
+ missing values (``na.rm = TRUE``), so a gene the alignment stage padded with
158
+ ``NaN`` stays ``NaN`` for the model's imputer to fill.
159
+
160
+ ``reference_index`` is a list of sample labels; ``None`` centres on the whole
161
+ cohort.
162
+ """
163
+ reference = df if reference_index is None else df.loc[reference_index]
164
+ if reference.empty:
165
+ raise ValueError("reference_index selects no samples")
166
+ return df.sub(reference.median(axis=0), axis=1)
167
+
168
+
169
+ def _load_mapping(dir: str) -> pd.DataFrame:
170
+ """Fetch the flattened source-ID -> mouse-Entrez lookup from the Hub."""
171
+ path = download_hf_file("tage_gene_mapping.csv.gz", dir, repo_id="pyaging/tage")
172
+ return pd.read_csv(path, dtype=str)
173
+
174
+
175
+ def _resolve_species(frame: pd.DataFrame, logger) -> tuple[str, pd.DataFrame]:
176
+ """Read the cohort's species off its indicator columns and drop them.
177
+
178
+ A species indicator is a column of the matrix, not a gene: one 0/1 value per
179
+ sample, the same for every sample, named after the species. Exactly one
180
+ indicator set to 1 picks the species; none at all -- or all of them zero --
181
+ means mouse, which is what the clocks were trained in. Names are matched
182
+ case-insensitively, so a ``Human`` column can never be read as an unlabelled
183
+ mouse cohort.
184
+
185
+ The indicators are dropped whether or not they were used. The gene mapping
186
+ would drop them anyway, but only after the gene filter had counted them, and
187
+ a column of ones is not a gene.
188
+ """
189
+ indicators = [name for name in frame.columns if str(name).strip().lower() in SPECIES_COLUMNS]
190
+ selected = []
191
+ for name in indicators:
192
+ values = frame[name].to_numpy(dtype=float)
193
+ unique = np.unique(values)
194
+ if unique.size != 1:
195
+ raise ValueError(
196
+ f"the {name!r} species indicator must hold the same value for every sample, "
197
+ f"got {unique.size} distinct values"
198
+ )
199
+ if unique[0] not in (0.0, 1.0):
200
+ raise ValueError(f"the {name!r} species indicator must be 0 or 1, got {unique[0]:g}")
201
+ if unique[0] == 1.0:
202
+ selected.append(str(name).strip().lower())
203
+
204
+ if len(selected) > 1:
205
+ raise ValueError(f"more than one species indicator is set to 1: {sorted(selected)}")
206
+
207
+ remaining = frame.drop(columns=indicators)
208
+ if not selected:
209
+ _warn(
210
+ logger,
211
+ "no species indicator column found; defaulting to mouse (add a column named "
212
+ f"{'/'.join(SPECIES_COLUMNS)}, set to 1 for every sample, to say otherwise)",
213
+ )
214
+ return "mouse", remaining
215
+ return selected[0], remaining
216
+
217
+
218
+ def _resolve_reference_group(adata) -> list | None:
219
+ """Read the samples to centre against from ``obs[REFERENCE_COLUMN]``.
220
+
221
+ Absent, the cohort centres on every sample -- the reference pipeline's own
222
+ default. Present, the truthy rows are the reference group.
223
+ """
224
+ if REFERENCE_COLUMN not in adata.obs.columns:
225
+ return None
226
+ column = adata.obs[REFERENCE_COLUMN]
227
+ values = np.asarray(column)
228
+ if values.dtype == bool:
229
+ mask = values
230
+ elif np.issubdtype(values.dtype, np.number):
231
+ mask = values != 0
232
+ else:
233
+ raise ValueError(f"adata.obs[{REFERENCE_COLUMN!r}] must be boolean (or 0/1), got dtype {values.dtype}")
234
+ names = list(adata.obs_names[mask])
235
+ if len(names) == 0:
236
+ raise ValueError(f"adata.obs[{REFERENCE_COLUMN!r}] selects no samples")
237
+ return names
238
+
239
+
240
+ def _prepare_tage(adata, dir: str = "pyaging_data", logger=None) -> pd.DataFrame:
241
+ """
242
+ Run the tAge cohort preprocessing on a matrix of raw RNA-seq counts.
243
+
244
+ Reproduces the ``scaled_diff`` branch of the reference package's
245
+ ``tAge_preprocessing()`` (Tyshkovskiy 2026): drop the species indicator
246
+ columns -> filter genes -> map to mouse Entrez IDs -> RLE normalise ->
247
+ ``log10(x + 1)`` -> z-score each sample -> subtract the reference group's
248
+ per-gene median. Matching the clocks' feature lists happens afterwards, back
249
+ in ``predict_age``.
250
+
251
+ ``predict_age`` calls this for any clock declaring ``cohort_transform =
252
+ "tage"``, once per call however many such clocks are requested; it is not a
253
+ step users perform themselves. The species and the reference group are read
254
+ off the input rather than passed as arguments, because by the time this runs
255
+ there is no user call to pass them to: the species comes from a 0/1
256
+ indicator column among ``var_names``, and the reference group from
257
+ ``obs["tage_reference_group"]``.
258
+
259
+ The tAge clocks are cohort-relative: every stage above uses statistics of the
260
+ whole input, so predictions depend on which samples are predicted together. A
261
+ single sample cannot be prepared, and a prediction is an age difference
262
+ against the reference group rather than an absolute age.
263
+
264
+ Two samples clear the hard minimum but are not enough to normalise against:
265
+ the RLE factors, the reference median, and the gene filter are all cohort
266
+ statistics, so a very small cohort (roughly fewer than ten samples, or a
267
+ reference group that small) makes them noisy and the resulting predictions
268
+ correspondingly unstable. Nothing here rejects such an input; interpret it
269
+ with that in mind.
270
+
271
+ Parameters
272
+ ----------
273
+ adata : anndata.AnnData
274
+ Samples x genes raw counts. ``var_names`` must be gene identifiers
275
+ (symbols, Ensembl, or Entrez IDs) of the cohort's species, optionally
276
+ alongside a species indicator column.
277
+ dir : str
278
+ Directory the gene mapping is downloaded to. Defaults to "pyaging_data".
279
+ logger : optional
280
+ Internal pipeline logger; warnings surface on the predict display, and
281
+ the two that would otherwise pass unnoticed -- the default to mouse and
282
+ a poor gene overlap -- are also raised as ``UserWarning`` so they survive
283
+ ``verbose=False``.
284
+
285
+ Returns
286
+ -------
287
+ pandas.DataFrame
288
+ The centred matrix, samples x mouse Entrez IDs. ``adata`` itself is left
289
+ untouched apart from ``uns["tage_preparation"]``, which records the run's
290
+ parameters and mapping statistics.
291
+
292
+ Raises
293
+ ------
294
+ ValueError
295
+ If the species indicators are inconsistent, fewer than two samples are
296
+ given, the reference column selects no samples, or no gene survives
297
+ filtering and mapping.
298
+
299
+ Notes
300
+ -----
301
+ One deliberate divergence from the reference: when its ``control_group_label``
302
+ matches no sample, ``control_subtraction`` falls back to centring on the whole
303
+ cohort with only a printed message. That silently answers a different question
304
+ than the one asked, so an empty reference column raises here instead.
305
+ """
306
+ if logger is None:
307
+ logger = _NullLogger()
308
+ if adata.n_obs < 2:
309
+ raise ValueError("the tAge clocks are cohort-relative and need at least two samples")
310
+
311
+ reference_index = _resolve_reference_group(adata)
312
+
313
+ # ``to_df`` densifies a sparse matrix and labels the axes; the ``astype``
314
+ # copies, so nothing downstream can reach the caller's counts and the other
315
+ # clocks in the same predict_age call still see the original data.
316
+ frame = adata.to_df().astype(np.float64)
317
+ species, frame = _resolve_species(frame, logger)
318
+ if species != "mouse":
319
+ logger.warning(
320
+ f"tage is calibrated in months of mouse age; a {species} cohort needs rescaling by its own "
321
+ "maximum lifespan over 48 months (tagemortality, a log hazard ratio, is never rescaled)",
322
+ indent_level=2,
323
+ )
324
+
325
+ filtered = _filter_genes(frame)
326
+ if filtered.shape[1] == 0:
327
+ raise ValueError(
328
+ f"no gene reached {COUNT_THRESHOLD} counts in {PERCENT_THRESHOLD}% of samples; "
329
+ "the tAge clocks expect raw RNA-seq counts, not normalized expression"
330
+ )
331
+
332
+ mapped = _map_to_mouse_entrez(filtered, species, _load_mapping(dir))
333
+ if mapped.shape[1] == 0:
334
+ raise ValueError(f"no input gene could be mapped to a mouse Entrez ID for species {species!r}")
335
+ # Overlap is judged against the filtered genes, not the raw input: an
336
+ # RNA-seq matrix carries tens of thousands of unexpressed genes that the
337
+ # filter drops, so the raw fraction says more about annotation size than
338
+ # about how well the input matches the mapping table.
339
+ if mapped.shape[1] < 0.5 * filtered.shape[1]:
340
+ _warn(
341
+ logger,
342
+ f"only {mapped.shape[1]} of {filtered.shape[1]} expressed genes mapped to mouse "
343
+ f"Entrez IDs; check that var_names are {species} gene identifiers",
344
+ )
345
+
346
+ centered = _center_against_reference(_scale_samples(_log_transform(_rle_normalize(mapped))), reference_index)
347
+
348
+ adata.uns["tage_preparation"] = {
349
+ "species": species,
350
+ "n_input_genes": int(frame.shape[1]),
351
+ "n_filtered_genes": int(filtered.shape[1]),
352
+ "n_mapped_genes": int(mapped.shape[1]),
353
+ "n_reference_samples": len(reference_index) if reference_index is not None else int(adata.n_obs),
354
+ "reference_group": list(reference_index) if reference_index is not None else "all_samples",
355
+ }
356
+ return centered
357
+
358
+
359
+ class _NullLogger:
360
+ """Swallow the pipeline log when the transform is called without a display."""
361
+
362
+ def __getattr__(self, name):
363
+ def _noop(*args, **kwargs):
364
+ return None
365
+
366
+ return _noop
@@ -159,7 +159,7 @@ def get_feature_ranges(clock_name: str):
159
159
  raise ValueError(f"clock {clock_name!r} has no feature list, so its feature ranges cannot be resolved")
160
160
  records = resolve_feature_ranges(
161
161
  model.features,
162
- model.metadata.get("data_type"),
162
+ getattr(model, "feature_range_data_type", None) or model.metadata.get("data_type"),
163
163
  getattr(model, "feature_units", None),
164
164
  )
165
165
  return pd.DataFrame.from_records(records)
@@ -1,101 +0,0 @@
1
- import math
2
-
3
- import torch
4
-
5
-
6
- def anti_log_linear(x, adult_age=20):
7
- """
8
- Applies an anti-logarithmic linear transformation to a value.
9
- """
10
- if x < 0:
11
- # Apply exponential transformation for negative values
12
- return (1 + adult_age) * math.exp(x) - 1
13
- else:
14
- # Apply linear transformation for non-negative values
15
- return (1 + adult_age) * x + adult_age
16
-
17
-
18
- def anti_logp2(x):
19
- """
20
- Applies an anti-logarithmic transformation with an offset of -2.
21
- """
22
- # Exponential transformation with an offset
23
- return math.exp(x) - 2
24
-
25
-
26
- def anti_log(x):
27
- """
28
- Applies a simple anti-logarithmic transformation.
29
- """
30
- # Simple exponential transformation
31
- return math.exp(x)
32
-
33
-
34
- def anti_log_log(x):
35
- """
36
- Applies a double transformation: logarithmic followed by anti-logarithmic.
37
- """
38
- # Double transformation: logarithmic followed by anti-logarithmic
39
- return math.exp(-math.exp(-x))
40
-
41
-
42
- def mortality_to_phenoage(x):
43
- """
44
- Applies a convertion from a CDF of the mortality score from a Gompertz
45
- distribution to phenotypic age.
46
- """
47
- # lambda
48
- lambda_ = 0.0192
49
- mortality_score = 1 - math.exp(-math.exp(x) * (math.exp(120 * lambda_) - 1) / lambda_)
50
- age = 141.50225 + math.log(-0.00553 * math.log(1 - mortality_score)) / 0.090165
51
- return age
52
-
53
-
54
- def mortality_to_phenoage_saopaulo(x, m_n, m_d, ba_n, ba_d, ba_i):
55
- """
56
- Applies a convertion from a CDF of the mortality score from a Gompertz
57
- distribution to phenotypic age, using constants supplied by the caller.
58
-
59
- ``mortality_to_phenoage`` hardcodes Levine's 2018 published constants. A
60
- refit of that model fits its own mortality-to-age constants alongside its
61
- coefficients, so they are passed in rather than baked in.
62
-
63
- Parameters
64
- ----------
65
- x : torch.Tensor
66
- The linear predictor, one value per sample.
67
-
68
- m_n, m_d : torch.Tensor
69
- Numerator and denominator of the Gompertz mortality exponent.
70
-
71
- ba_n, ba_d, ba_i : torch.Tensor
72
- Scale, rate, and intercept converting mortality back to years.
73
-
74
- Returns
75
- -------
76
- torch.Tensor
77
- Phenotypic age in years.
78
- """
79
- mortality_score = 1 - torch.exp(m_n * torch.exp(x) / m_d)
80
- return torch.log(ba_n * torch.log(1 - mortality_score)) / ba_d + ba_i
81
-
82
-
83
- def petkovichblood(x):
84
- """
85
- Applies a convertion from the output of an ElasticNet to mouse age in months.
86
- """
87
- a = 0.1666
88
- b = 0.4185
89
- c = -1.712
90
- age = ((x - c) / a) ** (1 / b)
91
- age = age / 30.5 # days to months
92
- return age
93
-
94
-
95
- def stubbsmultitissue(x):
96
- """
97
- Applies a convertion from the output of an ElasticNet to mouse age in months.
98
- """
99
- age = math.exp(0.1207 * (x**2) + 1.2424 * x + 2.5440) - 3
100
- age = age * (7 / 30.5) # weeks to months
101
- return age
File without changes
File without changes
File without changes