aging-metaclock 0.1.0__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.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: aging_metaclock
3
+ Version: 0.1.0
4
+ Summary: Reproducible analysis and plotting workflows for MetaClock
5
+ Requires-Python: <3.12,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: anndata==0.12.7
8
+ Requires-Dist: joblib==1.5.2
9
+ Requires-Dist: lightgbm==4.6.0
10
+ Requires-Dist: lifelines==0.30.3
11
+ Requires-Dist: matplotlib==3.10.8
12
+ Requires-Dist: mrmr-selection==0.2.8
13
+ Requires-Dist: numpy==2.3.5
14
+ Requires-Dist: pandas==2.3.3
15
+ Requires-Dist: pymupdf==1.26.6
16
+ Requires-Dist: pyarrow==23.0.1
17
+ Requires-Dist: pyaging==0.1.30
18
+ Requires-Dist: rdata==1.0.0
19
+ Requires-Dist: scikit-learn==1.8.0
20
+ Requires-Dist: scipy==1.17.0
21
+ Requires-Dist: statsmodels==0.14.6
22
+ Requires-Dist: torch==2.9.1
23
+
24
+ # MetaClock workflow
25
+
26
+ ![MetaClock workflow diagram](diagram.png)
27
+
28
+ This repository contains workflows for chronological-age
29
+ prediction, cross-domain generalization, biological-age and disease analysis,
30
+ functional enrichment, and intervention analysis. GEO workflows download inputs
31
+ into repository-local data directories. The workflow READMEs describe the required files and preparation
32
+ steps.
33
+
34
+ ## Project overview
35
+
36
+ MetaClock is an integrated framework that combines
37
+ predictions from multiple established epigenetic clocks into meta
38
+ models. This repository organizes the reproducible downstream workflows used
39
+ to evaluate chronological-age prediction, quantify generalization across array
40
+ platforms, tissues, and species, and study biological-age associations with
41
+ disease and interventions.
42
+
43
+ ## Environment
44
+
45
+ Install [uv](https://docs.astral.sh/uv/) and create the locked Python 3.11
46
+ environment from the repository root:
47
+
48
+ ```bash
49
+ uv sync --frozen
50
+ ```
51
+
52
+ ## MetaClock Python package
53
+
54
+ The `aging_metaclock` distribution provides the importable `metaclock` package.
55
+ Install it from PyPI using Python 3.11:
56
+
57
+ ```bash
58
+ python -m pip install aging_metaclock
59
+ ```
60
+
61
+ For local development, install from the repository root:
62
+
63
+ ```bash
64
+ python -m pip install -e .
65
+ ```
66
+
67
+ ### Input contract
68
+
69
+ `metaclock.fit()` accepts an in-memory `anndata.AnnData` object or an `.h5ad`
70
+ file in pyaging's standard orientation:
71
+
72
+ - rows are samples;
73
+ - columns are DNAm or phenotype features;
74
+ - feature names are stored in `adata.var_names`;
75
+ - the numeric training response is stored in `adata.obs["age"]` by default.
76
+
77
+ Clocks that use phenotype covariates, such as GrimAge, require those covariates
78
+ in the input matrix and `adata.var_names` according to pyaging's input rules.
79
+
80
+ ### Fit and predict
81
+
82
+ Pass any ordered list of pyaging component-model names. The fitted result is an
83
+ sklearn-compatible pipeline whose `predict()` method first runs the same
84
+ components in the same order and then applies the trained meta-regressor:
85
+
86
+ ```
87
+ import metaclock
88
+
89
+ component_model_list = [
90
+ "horvath2013",
91
+ "hannum",
92
+ "dnamphenoage",
93
+ ]
94
+
95
+ model = metaclock.fit(
96
+ train_adata,
97
+ component_model_list,
98
+ "elastic_net",
99
+ target="age",
100
+ )
101
+
102
+ predicted_age = model.predict(test_adata)
103
+ ```
104
+
105
+ The three positional arguments are the input data, component-model list, and
106
+ model architecture. Set `pyaging_dir` when using a specific local pyaging
107
+ weight cache. The component ordering used for fitting is retained for every
108
+ later prediction.
109
+
110
+ To run the component clocks without fitting a meta-model:
111
+
112
+ ```python
113
+ component_outputs = metaclock.predict_components(
114
+ test_adata,
115
+ component_model_list,
116
+ )
117
+ ```
118
+
119
+ Supported architectures are `elastic_net`, `lasso`, `ridge`,
120
+ `linear_regression`, `random_forest`, `svr`, `mlp`, and `lightgbm`. The `mlp`
121
+ architecture uses `StandardScaler` followed by sklearn's `MLPRegressor`; its
122
+ network parameters can be overridden through `model_params`. LightGBM is part
123
+ of the standard locked environment and uses its sklearn-compatible
124
+ `LGBMRegressor` interface.
125
+
126
+ Fitted models can be saved with joblib and loaded for later prediction:
127
+
128
+ ```python
129
+ import joblib
130
+
131
+ joblib.dump(model, model_output_path)
132
+ loaded_model = joblib.load(model_output_path)
133
+ predicted_age = loaded_model.predict(test_adata)
134
+ ```
135
+
@@ -0,0 +1,112 @@
1
+ # MetaClock workflow
2
+
3
+ ![MetaClock workflow diagram](diagram.png)
4
+
5
+ This repository contains workflows for chronological-age
6
+ prediction, cross-domain generalization, biological-age and disease analysis,
7
+ functional enrichment, and intervention analysis. GEO workflows download inputs
8
+ into repository-local data directories. The workflow READMEs describe the required files and preparation
9
+ steps.
10
+
11
+ ## Project overview
12
+
13
+ MetaClock is an integrated framework that combines
14
+ predictions from multiple established epigenetic clocks into meta
15
+ models. This repository organizes the reproducible downstream workflows used
16
+ to evaluate chronological-age prediction, quantify generalization across array
17
+ platforms, tissues, and species, and study biological-age associations with
18
+ disease and interventions.
19
+
20
+ ## Environment
21
+
22
+ Install [uv](https://docs.astral.sh/uv/) and create the locked Python 3.11
23
+ environment from the repository root:
24
+
25
+ ```bash
26
+ uv sync --frozen
27
+ ```
28
+
29
+ ## MetaClock Python package
30
+
31
+ The `aging_metaclock` distribution provides the importable `metaclock` package.
32
+ Install it from PyPI using Python 3.11:
33
+
34
+ ```bash
35
+ python -m pip install aging_metaclock
36
+ ```
37
+
38
+ For local development, install from the repository root:
39
+
40
+ ```bash
41
+ python -m pip install -e .
42
+ ```
43
+
44
+ ### Input contract
45
+
46
+ `metaclock.fit()` accepts an in-memory `anndata.AnnData` object or an `.h5ad`
47
+ file in pyaging's standard orientation:
48
+
49
+ - rows are samples;
50
+ - columns are DNAm or phenotype features;
51
+ - feature names are stored in `adata.var_names`;
52
+ - the numeric training response is stored in `adata.obs["age"]` by default.
53
+
54
+ Clocks that use phenotype covariates, such as GrimAge, require those covariates
55
+ in the input matrix and `adata.var_names` according to pyaging's input rules.
56
+
57
+ ### Fit and predict
58
+
59
+ Pass any ordered list of pyaging component-model names. The fitted result is an
60
+ sklearn-compatible pipeline whose `predict()` method first runs the same
61
+ components in the same order and then applies the trained meta-regressor:
62
+
63
+ ```
64
+ import metaclock
65
+
66
+ component_model_list = [
67
+ "horvath2013",
68
+ "hannum",
69
+ "dnamphenoage",
70
+ ]
71
+
72
+ model = metaclock.fit(
73
+ train_adata,
74
+ component_model_list,
75
+ "elastic_net",
76
+ target="age",
77
+ )
78
+
79
+ predicted_age = model.predict(test_adata)
80
+ ```
81
+
82
+ The three positional arguments are the input data, component-model list, and
83
+ model architecture. Set `pyaging_dir` when using a specific local pyaging
84
+ weight cache. The component ordering used for fitting is retained for every
85
+ later prediction.
86
+
87
+ To run the component clocks without fitting a meta-model:
88
+
89
+ ```python
90
+ component_outputs = metaclock.predict_components(
91
+ test_adata,
92
+ component_model_list,
93
+ )
94
+ ```
95
+
96
+ Supported architectures are `elastic_net`, `lasso`, `ridge`,
97
+ `linear_regression`, `random_forest`, `svr`, `mlp`, and `lightgbm`. The `mlp`
98
+ architecture uses `StandardScaler` followed by sklearn's `MLPRegressor`; its
99
+ network parameters can be overridden through `model_params`. LightGBM is part
100
+ of the standard locked environment and uses its sklearn-compatible
101
+ `LGBMRegressor` interface.
102
+
103
+ Fitted models can be saved with joblib and loaded for later prediction:
104
+
105
+ ```python
106
+ import joblib
107
+
108
+ joblib.dump(model, model_output_path)
109
+ loaded_model = joblib.load(model_output_path)
110
+ predicted_age = loaded_model.predict(test_adata)
111
+ ```
112
+
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: aging_metaclock
3
+ Version: 0.1.0
4
+ Summary: Reproducible analysis and plotting workflows for MetaClock
5
+ Requires-Python: <3.12,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: anndata==0.12.7
8
+ Requires-Dist: joblib==1.5.2
9
+ Requires-Dist: lightgbm==4.6.0
10
+ Requires-Dist: lifelines==0.30.3
11
+ Requires-Dist: matplotlib==3.10.8
12
+ Requires-Dist: mrmr-selection==0.2.8
13
+ Requires-Dist: numpy==2.3.5
14
+ Requires-Dist: pandas==2.3.3
15
+ Requires-Dist: pymupdf==1.26.6
16
+ Requires-Dist: pyarrow==23.0.1
17
+ Requires-Dist: pyaging==0.1.30
18
+ Requires-Dist: rdata==1.0.0
19
+ Requires-Dist: scikit-learn==1.8.0
20
+ Requires-Dist: scipy==1.17.0
21
+ Requires-Dist: statsmodels==0.14.6
22
+ Requires-Dist: torch==2.9.1
23
+
24
+ # MetaClock workflow
25
+
26
+ ![MetaClock workflow diagram](diagram.png)
27
+
28
+ This repository contains workflows for chronological-age
29
+ prediction, cross-domain generalization, biological-age and disease analysis,
30
+ functional enrichment, and intervention analysis. GEO workflows download inputs
31
+ into repository-local data directories. The workflow READMEs describe the required files and preparation
32
+ steps.
33
+
34
+ ## Project overview
35
+
36
+ MetaClock is an integrated framework that combines
37
+ predictions from multiple established epigenetic clocks into meta
38
+ models. This repository organizes the reproducible downstream workflows used
39
+ to evaluate chronological-age prediction, quantify generalization across array
40
+ platforms, tissues, and species, and study biological-age associations with
41
+ disease and interventions.
42
+
43
+ ## Environment
44
+
45
+ Install [uv](https://docs.astral.sh/uv/) and create the locked Python 3.11
46
+ environment from the repository root:
47
+
48
+ ```bash
49
+ uv sync --frozen
50
+ ```
51
+
52
+ ## MetaClock Python package
53
+
54
+ The `aging_metaclock` distribution provides the importable `metaclock` package.
55
+ Install it from PyPI using Python 3.11:
56
+
57
+ ```bash
58
+ python -m pip install aging_metaclock
59
+ ```
60
+
61
+ For local development, install from the repository root:
62
+
63
+ ```bash
64
+ python -m pip install -e .
65
+ ```
66
+
67
+ ### Input contract
68
+
69
+ `metaclock.fit()` accepts an in-memory `anndata.AnnData` object or an `.h5ad`
70
+ file in pyaging's standard orientation:
71
+
72
+ - rows are samples;
73
+ - columns are DNAm or phenotype features;
74
+ - feature names are stored in `adata.var_names`;
75
+ - the numeric training response is stored in `adata.obs["age"]` by default.
76
+
77
+ Clocks that use phenotype covariates, such as GrimAge, require those covariates
78
+ in the input matrix and `adata.var_names` according to pyaging's input rules.
79
+
80
+ ### Fit and predict
81
+
82
+ Pass any ordered list of pyaging component-model names. The fitted result is an
83
+ sklearn-compatible pipeline whose `predict()` method first runs the same
84
+ components in the same order and then applies the trained meta-regressor:
85
+
86
+ ```
87
+ import metaclock
88
+
89
+ component_model_list = [
90
+ "horvath2013",
91
+ "hannum",
92
+ "dnamphenoage",
93
+ ]
94
+
95
+ model = metaclock.fit(
96
+ train_adata,
97
+ component_model_list,
98
+ "elastic_net",
99
+ target="age",
100
+ )
101
+
102
+ predicted_age = model.predict(test_adata)
103
+ ```
104
+
105
+ The three positional arguments are the input data, component-model list, and
106
+ model architecture. Set `pyaging_dir` when using a specific local pyaging
107
+ weight cache. The component ordering used for fitting is retained for every
108
+ later prediction.
109
+
110
+ To run the component clocks without fitting a meta-model:
111
+
112
+ ```python
113
+ component_outputs = metaclock.predict_components(
114
+ test_adata,
115
+ component_model_list,
116
+ )
117
+ ```
118
+
119
+ Supported architectures are `elastic_net`, `lasso`, `ridge`,
120
+ `linear_regression`, `random_forest`, `svr`, `mlp`, and `lightgbm`. The `mlp`
121
+ architecture uses `StandardScaler` followed by sklearn's `MLPRegressor`; its
122
+ network parameters can be overridden through `model_params`. LightGBM is part
123
+ of the standard locked environment and uses its sklearn-compatible
124
+ `LGBMRegressor` interface.
125
+
126
+ Fitted models can be saved with joblib and loaded for later prediction:
127
+
128
+ ```python
129
+ import joblib
130
+
131
+ joblib.dump(model, model_output_path)
132
+ loaded_model = joblib.load(model_output_path)
133
+ predicted_age = loaded_model.predict(test_adata)
134
+ ```
135
+
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ aging_metaclock.egg-info/PKG-INFO
4
+ aging_metaclock.egg-info/SOURCES.txt
5
+ aging_metaclock.egg-info/dependency_links.txt
6
+ aging_metaclock.egg-info/requires.txt
7
+ aging_metaclock.egg-info/top_level.txt
8
+ metaclock/__init__.py
9
+ metaclock/_version.py
10
+ metaclock/api.py
11
+ metaclock/components.py
12
+ metaclock/models.py
13
+ metaclock/presets.py
@@ -0,0 +1,16 @@
1
+ anndata==0.12.7
2
+ joblib==1.5.2
3
+ lightgbm==4.6.0
4
+ lifelines==0.30.3
5
+ matplotlib==3.10.8
6
+ mrmr-selection==0.2.8
7
+ numpy==2.3.5
8
+ pandas==2.3.3
9
+ pymupdf==1.26.6
10
+ pyarrow==23.0.1
11
+ pyaging==0.1.30
12
+ rdata==1.0.0
13
+ scikit-learn==1.8.0
14
+ scipy==1.17.0
15
+ statsmodels==0.14.6
16
+ torch==2.9.1
@@ -0,0 +1,17 @@
1
+ """MetaClock: fit meta-regressors from pyaging component clocks."""
2
+
3
+ from ._version import __version__
4
+ from .api import fit
5
+ from .components import PyAgingTransformer, predict_components
6
+ from .models import available_architectures, make_estimator
7
+ from .presets import CHRONOLOGICAL_16_COMPONENT_MODELS
8
+
9
+ __all__ = [
10
+ "PyAgingTransformer",
11
+ "CHRONOLOGICAL_16_COMPONENT_MODELS",
12
+ "__version__",
13
+ "available_architectures",
14
+ "fit",
15
+ "make_estimator",
16
+ "predict_components",
17
+ ]
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,165 @@
1
+ """Public MetaClock fitting API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from os import PathLike
8
+ from typing import TYPE_CHECKING, Any, Mapping, Sequence
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from sklearn.pipeline import Pipeline
13
+
14
+ from ._version import __version__
15
+ from .components import (
16
+ InputData,
17
+ PyAgingTransformer,
18
+ load_anndata,
19
+ normalize_component_models,
20
+ )
21
+ from .models import make_estimator, normalize_architecture
22
+
23
+ if TYPE_CHECKING:
24
+ from anndata import AnnData
25
+
26
+
27
+ def _package_version(distribution: str) -> str:
28
+ try:
29
+ return version(distribution)
30
+ except PackageNotFoundError:
31
+ return "not-installed"
32
+
33
+
34
+ def _extract_target(adata: AnnData, target: str) -> np.ndarray:
35
+ if not isinstance(target, str) or not target.strip():
36
+ raise TypeError("target must be a non-empty adata.obs column name.")
37
+ if target not in adata.obs.columns:
38
+ raise KeyError(f"AnnData obs does not contain target column {target!r}.")
39
+ series = pd.to_numeric(adata.obs[target], errors="coerce")
40
+ values = series.to_numpy(dtype=np.float64, copy=True)
41
+ if not np.isfinite(values).all():
42
+ affected = adata.obs_names[~np.isfinite(values)].astype(str).tolist()[:5]
43
+ raise ValueError(
44
+ f"Target column {target!r} contains missing or non-finite values; "
45
+ f"first affected samples: {affected}"
46
+ )
47
+ return values
48
+
49
+
50
+ def _safe_params(value: Any) -> Any:
51
+ if value is None or isinstance(value, (str, int, float, bool)):
52
+ return value
53
+ if isinstance(value, Mapping):
54
+ return {str(key): _safe_params(item) for key, item in value.items()}
55
+ if isinstance(value, (list, tuple)):
56
+ return [_safe_params(item) for item in value]
57
+ if isinstance(value, np.ndarray):
58
+ return value.tolist()
59
+ return repr(value)
60
+
61
+
62
+ def fit(
63
+ input_data: InputData,
64
+ component_models: Sequence[str] | str,
65
+ architecture: str,
66
+ *,
67
+ target: str = "age",
68
+ pyaging_dir: str | PathLike[str] | None = "pyaging_data",
69
+ batch_size: int = 1024,
70
+ clean: bool = True,
71
+ verbose: bool = False,
72
+ copy: bool = False,
73
+ random_state: int = 42,
74
+ n_jobs: int | None = 1,
75
+ model_params: Mapping[str, Any] | None = None,
76
+ ) -> Pipeline:
77
+ """Fit a MetaClock from a pyaging-standard AnnData input.
78
+
79
+ Parameters
80
+ ----------
81
+ input_data
82
+ An ``AnnData`` object or an ``.h5ad`` path. Rows are samples, columns
83
+ are pyaging features, and ``adata.obs[target]`` supplies the response.
84
+ component_models
85
+ One or more pyaging clock names. Their prediction order is preserved.
86
+ architecture
87
+ A name returned by ``metaclock.available_architectures()``.
88
+ target
89
+ Numeric ``adata.obs`` target column. Defaults to ``"age"``.
90
+ pyaging_dir
91
+ PyAging model-cache directory, or ``None`` to use PyAging's default.
92
+ batch_size, clean, verbose
93
+ Values forwarded to PyAging component-clock inference.
94
+ copy
95
+ If true, copy in-memory ``AnnData`` before PyAging inference. With the
96
+ default false, PyAging may update the supplied object in place.
97
+ random_state
98
+ Seed used by regressors and shuffled cross-validation where supported.
99
+ n_jobs
100
+ Worker count forwarded to regressors that support parallel execution.
101
+ model_params
102
+ Overrides passed directly to the final regression estimator.
103
+
104
+ Returns
105
+ -------
106
+ sklearn.pipeline.Pipeline
107
+ A fitted pipeline. Calling ``predict()`` with another pyaging-standard
108
+ ``AnnData`` object or ``.h5ad`` path reruns the same component clocks
109
+ before applying the fitted regressor.
110
+ """
111
+ adata = load_anndata(input_data)
112
+ components = normalize_component_models(component_models)
113
+ architecture_name = normalize_architecture(architecture)
114
+ if target.lower() in components:
115
+ raise ValueError("target cannot also be a component-model output column.")
116
+ y = _extract_target(adata, target)
117
+
118
+ transformer = PyAgingTransformer(
119
+ components,
120
+ pyaging_dir=pyaging_dir,
121
+ batch_size=batch_size,
122
+ clean=clean,
123
+ verbose=verbose,
124
+ copy=copy,
125
+ )
126
+ estimator = make_estimator(
127
+ architecture_name,
128
+ random_state=random_state,
129
+ n_jobs=n_jobs,
130
+ model_params=model_params,
131
+ )
132
+ model = Pipeline([("components", transformer), ("regressor", estimator)])
133
+ model.fit(adata, y)
134
+
135
+ model.metaclock_architecture_ = architecture_name
136
+ model.metaclock_component_models_ = components
137
+ model.metaclock_target_ = target
138
+ model.metaclock_provenance_ = {
139
+ "package_versions": {
140
+ "python": platform.python_version(),
141
+ "metaclock": __version__,
142
+ "pyaging": _package_version("pyaging"),
143
+ "anndata": _package_version("anndata"),
144
+ "numpy": _package_version("numpy"),
145
+ "pandas": _package_version("pandas"),
146
+ "scikit-learn": _package_version("scikit-learn"),
147
+ "lightgbm": _package_version("lightgbm")
148
+ if architecture_name == "lightgbm"
149
+ else None,
150
+ },
151
+ "fit_parameters": {
152
+ "target": target,
153
+ "component_models": list(components),
154
+ "architecture": architecture_name,
155
+ "pyaging_dir": None if pyaging_dir is None else str(pyaging_dir),
156
+ "batch_size": batch_size,
157
+ "clean": clean,
158
+ "random_state": random_state,
159
+ "n_jobs": n_jobs,
160
+ "model_params": _safe_params(dict(model_params or {})),
161
+ },
162
+ "training_samples": int(adata.n_obs),
163
+ "component_missing_feature_percent": dict(transformer.missing_feature_percent_),
164
+ }
165
+ return model
@@ -0,0 +1,210 @@
1
+ """pyaging adapter for generating MetaClock component-model features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, Sequence, TypeAlias, Union
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from sklearn.base import BaseEstimator, TransformerMixin
12
+ from sklearn.utils.validation import check_is_fitted
13
+
14
+
15
+ if TYPE_CHECKING:
16
+ from anndata import AnnData
17
+
18
+ InputData: TypeAlias = Union["AnnData", str, PathLike[str]]
19
+
20
+
21
+ def normalize_component_models(
22
+ component_models: Sequence[str] | str,
23
+ ) -> tuple[str, ...]:
24
+ """Return non-empty, unique, lower-case pyaging clock names."""
25
+ if isinstance(component_models, str):
26
+ raw_models = [component_models]
27
+ else:
28
+ try:
29
+ raw_models = list(component_models)
30
+ except TypeError as exc:
31
+ raise TypeError(
32
+ "component_models must be a clock name or a sequence of names."
33
+ ) from exc
34
+
35
+ normalized: list[str] = []
36
+ for model in raw_models:
37
+ if not isinstance(model, str):
38
+ raise TypeError("Every component model name must be a string.")
39
+ name = model.strip().lower()
40
+ if not name:
41
+ raise ValueError("Component model names cannot be empty.")
42
+ normalized.append(name)
43
+
44
+ if not normalized:
45
+ raise ValueError("At least one component model is required.")
46
+ duplicates = sorted({name for name in normalized if normalized.count(name) > 1})
47
+ if duplicates:
48
+ raise ValueError(f"component_models contains duplicates: {duplicates}")
49
+ return tuple(normalized)
50
+
51
+
52
+ def load_anndata(input_data: InputData) -> AnnData:
53
+ """Load a pyaging-standard AnnData object from memory or an .h5ad file."""
54
+ try:
55
+ import anndata as ad
56
+ except ImportError as exc: # pragma: no cover - declared core dependency
57
+ raise ImportError("anndata is required to load MetaClock input data.") from exc
58
+
59
+ if isinstance(input_data, ad.AnnData):
60
+ adata = input_data
61
+ elif isinstance(input_data, (str, PathLike)):
62
+ path = Path(input_data).expanduser()
63
+ if not path.is_file():
64
+ raise FileNotFoundError(f"AnnData input file does not exist: {path}")
65
+ try:
66
+ adata = ad.read_h5ad(path)
67
+ except Exception as exc:
68
+ raise ValueError(f"Could not read AnnData .h5ad input: {path}") from exc
69
+ else:
70
+ raise TypeError(
71
+ "input_data must be an anndata.AnnData object or a path to an .h5ad file."
72
+ )
73
+
74
+ if adata.n_obs == 0:
75
+ raise ValueError("AnnData input has no samples.")
76
+ if adata.n_vars == 0:
77
+ raise ValueError("AnnData input has no features.")
78
+ if not adata.obs_names.is_unique:
79
+ raise ValueError("AnnData obs_names must be unique.")
80
+ if not adata.var_names.is_unique:
81
+ raise ValueError("AnnData var_names must be unique.")
82
+ return adata.copy() if adata.is_view else adata
83
+
84
+
85
+ class PyAgingTransformer(TransformerMixin, BaseEstimator):
86
+ """Transform a pyaging-standard AnnData object into component predictions.
87
+
88
+ The transformer deliberately delegates clock loading, feature alignment,
89
+ preprocessing, inference, and postprocessing to pyaging. Its output column
90
+ order always matches ``component_models``.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ component_models: Sequence[str] | str,
96
+ *,
97
+ pyaging_dir: str | PathLike[str] | None = "pyaging_data",
98
+ batch_size: int = 1024,
99
+ clean: bool = True,
100
+ verbose: bool = False,
101
+ copy: bool = False,
102
+ ) -> None:
103
+ self.component_models = component_models
104
+ self.pyaging_dir = pyaging_dir
105
+ self.batch_size = batch_size
106
+ self.clean = clean
107
+ self.verbose = verbose
108
+ self.copy = copy
109
+
110
+ def fit(self, X: InputData, y: Any = None) -> PyAgingTransformer:
111
+ """Validate clock names and record their output order without fitting clocks."""
112
+ del X, y
113
+ self.component_models_ = normalize_component_models(self.component_models)
114
+ if not isinstance(self.batch_size, int) or self.batch_size < 1:
115
+ raise ValueError("batch_size must be a positive integer.")
116
+ self.n_features_out_ = len(self.component_models_)
117
+ return self
118
+
119
+ def transform(self, X: InputData) -> pd.DataFrame:
120
+ """Run PyAging in place (on a copy if requested) and return finite predictions."""
121
+ check_is_fitted(self, "component_models_")
122
+ adata = load_anndata(X)
123
+ if self.copy:
124
+ adata = adata.copy()
125
+
126
+ kwargs: dict[str, Any] = {
127
+ "clock_names": list(self.component_models_),
128
+ "batch_size": self.batch_size,
129
+ "clean": self.clean,
130
+ "verbose": self.verbose,
131
+ }
132
+ if self.pyaging_dir is not None:
133
+ kwargs["dir"] = str(self.pyaging_dir)
134
+
135
+ import pyaging as pya
136
+
137
+ # PyAging 0.1.30 populates adata.obs in place and returns None.
138
+ pya.pred.predict_age(adata, **kwargs)
139
+
140
+ column_lookup: dict[str, Any] = {}
141
+ for column in adata.obs.columns:
142
+ lowered = str(column).lower()
143
+ if lowered in column_lookup and column_lookup[lowered] != column:
144
+ raise ValueError(
145
+ f"AnnData obs has ambiguous case-insensitive column {lowered!r}."
146
+ )
147
+ column_lookup[lowered] = column
148
+ missing = [name for name in self.component_models_ if name not in column_lookup]
149
+ if missing:
150
+ raise RuntimeError(
151
+ f"pyaging did not create component output columns: {missing}"
152
+ )
153
+
154
+ ordered_columns = [column_lookup[name] for name in self.component_models_]
155
+ frame = adata.obs.loc[:, ordered_columns].apply(pd.to_numeric, errors="coerce")
156
+ values = frame.to_numpy(dtype=np.float64, copy=True)
157
+ if not np.isfinite(values).all():
158
+ bad_rows, bad_columns = np.where(~np.isfinite(values))
159
+ examples = [
160
+ (str(adata.obs_names[row]), self.component_models_[column])
161
+ for row, column in zip(bad_rows[:5], bad_columns[:5])
162
+ ]
163
+ raise ValueError(
164
+ "pyaging component outputs contain missing or non-finite values; "
165
+ f"first affected (sample, component) pairs: {examples}"
166
+ )
167
+
168
+ self.missing_feature_percent_ = {
169
+ name: float(adata.uns.get(f"{name}_percent_na", np.nan))
170
+ for name in self.component_models_
171
+ }
172
+ return pd.DataFrame(
173
+ values,
174
+ index=adata.obs_names.copy(),
175
+ columns=self.get_feature_names_out(),
176
+ )
177
+
178
+ def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
179
+ """Return fitted component names in prediction-column order."""
180
+ del input_features
181
+ check_is_fitted(self, "component_models_")
182
+ return np.asarray(self.component_models_, dtype=object)
183
+
184
+
185
+ def predict_components(
186
+ input_data: InputData,
187
+ component_models: Sequence[str] | str,
188
+ *,
189
+ pyaging_dir: str | PathLike[str] | None = "pyaging_data",
190
+ batch_size: int = 1024,
191
+ clean: bool = True,
192
+ verbose: bool = False,
193
+ copy: bool = False,
194
+ ) -> pd.DataFrame:
195
+ """Return ordered pyaging component predictions as a DataFrame."""
196
+ adata = load_anndata(input_data)
197
+ transformer = PyAgingTransformer(
198
+ component_models,
199
+ pyaging_dir=pyaging_dir,
200
+ batch_size=batch_size,
201
+ clean=clean,
202
+ verbose=verbose,
203
+ copy=copy,
204
+ )
205
+ values = transformer.fit_transform(adata)
206
+ return pd.DataFrame(
207
+ values,
208
+ index=adata.obs_names.copy(),
209
+ columns=transformer.get_feature_names_out(),
210
+ )
@@ -0,0 +1,134 @@
1
+ """Maintained sklearn and LightGBM MetaClock estimator factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import Any, Mapping
7
+
8
+ import numpy as np
9
+ from sklearn.base import BaseEstimator
10
+ from sklearn.ensemble import RandomForestRegressor
11
+ from sklearn.linear_model import ElasticNetCV, LassoCV, LinearRegression, RidgeCV
12
+ from sklearn.model_selection import KFold
13
+ from sklearn.neural_network import MLPRegressor
14
+ from sklearn.pipeline import Pipeline
15
+ from sklearn.preprocessing import StandardScaler
16
+ from sklearn.svm import SVR
17
+
18
+
19
+ _ARCHITECTURES = (
20
+ "elastic_net",
21
+ "lasso",
22
+ "ridge",
23
+ "linear_regression",
24
+ "random_forest",
25
+ "svr",
26
+ "mlp",
27
+ "lightgbm",
28
+ )
29
+ _ALIASES = {
30
+ "elasticnet": "elastic_net",
31
+ "enet": "elastic_net",
32
+ "linear": "linear_regression",
33
+ "ols": "linear_regression",
34
+ "rf": "random_forest",
35
+ "randomforest": "random_forest",
36
+ "svm": "svr",
37
+ "mlp_regressor": "mlp",
38
+ "neural_network": "mlp",
39
+ "lgbm": "lightgbm",
40
+ }
41
+
42
+
43
+ def available_architectures() -> tuple[str, ...]:
44
+ """Return supported canonical architecture names."""
45
+ return _ARCHITECTURES
46
+
47
+
48
+ def normalize_architecture(architecture: str) -> str:
49
+ """Normalize architecture spelling and aliases."""
50
+ if not isinstance(architecture, str):
51
+ raise TypeError("architecture must be a string.")
52
+ name = architecture.strip().lower().replace("-", "_").replace(" ", "_")
53
+ name = _ALIASES.get(name, name)
54
+ if name not in _ARCHITECTURES:
55
+ choices = ", ".join(_ARCHITECTURES)
56
+ raise ValueError(
57
+ f"Unknown architecture {architecture!r}. Choose one of: {choices}."
58
+ )
59
+ return name
60
+
61
+
62
+ def _scaled(model: BaseEstimator) -> Pipeline:
63
+ return Pipeline([("scaler", StandardScaler()), ("model", model)])
64
+
65
+
66
+ def make_estimator(
67
+ architecture: str,
68
+ *,
69
+ random_state: int = 42,
70
+ n_jobs: int | None = 1,
71
+ model_params: Mapping[str, Any] | None = None,
72
+ ) -> BaseEstimator:
73
+ """Build an unfitted regressor using maintained package implementations.
74
+
75
+ ``model_params`` are passed directly to the final estimator. Scaled linear
76
+ models are returned as sklearn Pipelines with ``scaler`` and ``model``
77
+ steps. LightGBM is imported only when requested.
78
+ """
79
+ name = normalize_architecture(architecture)
80
+ params = dict(model_params or {})
81
+ alphas = np.logspace(-5, 0, 30)
82
+ cv = KFold(n_splits=5, shuffle=True, random_state=random_state)
83
+
84
+ if name == "elastic_net":
85
+ defaults: dict[str, Any] = {
86
+ "alphas": alphas,
87
+ "cv": cv,
88
+ "max_iter": 20_000,
89
+ "n_jobs": n_jobs,
90
+ }
91
+ defaults.update(params)
92
+ return _scaled(ElasticNetCV(**defaults))
93
+ if name == "lasso":
94
+ defaults = {
95
+ "alphas": alphas,
96
+ "cv": cv,
97
+ "max_iter": 20_000,
98
+ "n_jobs": n_jobs,
99
+ "random_state": random_state,
100
+ }
101
+ defaults.update(params)
102
+ return _scaled(LassoCV(**defaults))
103
+ if name == "ridge":
104
+ defaults = {"alphas": alphas, "cv": cv}
105
+ defaults.update(params)
106
+ return _scaled(RidgeCV(**defaults))
107
+ if name == "linear_regression":
108
+ defaults = {"n_jobs": n_jobs}
109
+ defaults.update(params)
110
+ return _scaled(LinearRegression(**defaults))
111
+ if name == "random_forest":
112
+ defaults = {"random_state": random_state, "n_jobs": n_jobs}
113
+ defaults.update(params)
114
+ return RandomForestRegressor(**defaults)
115
+ if name == "svr":
116
+ defaults = {"kernel": "linear"}
117
+ defaults.update(params)
118
+ return _scaled(SVR(**defaults))
119
+ if name == "mlp":
120
+ defaults = {"max_iter": 2_000, "random_state": random_state}
121
+ defaults.update(params)
122
+ return _scaled(MLPRegressor(**defaults))
123
+
124
+ try:
125
+ module = import_module("lightgbm")
126
+ regressor = module.LGBMRegressor
127
+ except (ImportError, AttributeError) as exc:
128
+ raise ImportError(
129
+ "architecture='lightgbm' requires LightGBM, which is a core "
130
+ "metaclock-bio dependency. Reinstall the package dependencies."
131
+ ) from exc
132
+ defaults = {"random_state": random_state, "n_jobs": n_jobs, "verbosity": -1}
133
+ defaults.update(params)
134
+ return regressor(**defaults)
@@ -0,0 +1,26 @@
1
+ """Published MetaClock component-model presets."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ CHRONOLOGICAL_16_COMPONENT_MODELS: tuple[str, ...] = (
7
+ "horvath2013",
8
+ "hannum",
9
+ "dnamphenoage",
10
+ "altumage",
11
+ "han",
12
+ "encen40",
13
+ "hrsinchphenoage",
14
+ "lin",
15
+ "skinandblood",
16
+ "yingcausage",
17
+ "zhangblup",
18
+ "zhangen",
19
+ "pchorvath2013",
20
+ "pchannum",
21
+ "pcphenoage",
22
+ "pcskinandblood",
23
+ )
24
+
25
+
26
+ __all__ = ["CHRONOLOGICAL_16_COMPONENT_MODELS"]
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aging_metaclock"
7
+ version = "0.1.0"
8
+ description = "Reproducible analysis and plotting workflows for MetaClock"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11,<3.12"
11
+ dependencies = [
12
+ "anndata==0.12.7",
13
+ "joblib==1.5.2",
14
+ "lightgbm==4.6.0",
15
+ "lifelines==0.30.3",
16
+ "matplotlib==3.10.8",
17
+ "mrmr-selection==0.2.8",
18
+ "numpy==2.3.5",
19
+ "pandas==2.3.3",
20
+ "pymupdf==1.26.6",
21
+ "pyarrow==23.0.1",
22
+ "pyaging==0.1.30",
23
+ "rdata==1.0.0",
24
+ "scikit-learn==1.8.0",
25
+ "scipy==1.17.0",
26
+ "statsmodels==0.14.6",
27
+ "torch==2.9.1",
28
+ ]
29
+
30
+ [tool.uv]
31
+ package = true
32
+
33
+ [tool.setuptools]
34
+ packages = ["metaclock"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+