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.
- {pyaging-0.5.0 → pyaging-0.5.2}/.gitignore +11 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/PKG-INFO +1 -1
- {pyaging-0.5.0 → pyaging-0.5.2}/pyproject.toml +1 -1
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/__init__.py +1 -1
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/feature_ranges.json +1 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/__init__.py +8 -8
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/_base_models.py +13 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/models/_models.py +103 -116
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/__init__.py +1 -17
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_pred.py +44 -2
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_pred_utils.py +114 -5
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/predict/_transforms.py +29 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/__init__.py +2 -0
- pyaging-0.5.2/src/pyaging/preprocess/_tage.py +366 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/_feature_ranges.py +1 -1
- pyaging-0.5.0/src/pyaging/predict/_inverse_transforms.py +0 -101
- {pyaging-0.5.0 → pyaging-0.5.2}/LICENSE +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/README.md +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/__init__.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/data/_data.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/__init__.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/_live.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/logger/_logger.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/_preprocess.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/preprocess/_preprocess_utils.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/py.typed +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/__init__.py +0 -0
- {pyaging-0.5.0 → pyaging-0.5.2}/src/pyaging/utils/_hf.py +0 -0
- {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.
|
|
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 = [
|
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
-
"
|
|
203
|
-
"
|
|
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.
|
|
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.
|
|
2121
|
-
self.
|
|
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.
|
|
2127
|
-
self.
|
|
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
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2142
|
+
female_components = torch.concat(
|
|
2153
2143
|
[
|
|
2154
|
-
(
|
|
2155
|
-
(
|
|
2156
|
-
(
|
|
2157
|
-
(
|
|
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
|
-
(
|
|
2165
|
-
(
|
|
2166
|
-
(
|
|
2167
|
-
(
|
|
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
|
-
|
|
2173
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
186
|
-
feature_indices = {feature: i for i, feature in enumerate(
|
|
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] =
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|