combatlearn 2.2.0__tar.gz → 2.3.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.
- {combatlearn-2.2.0/combatlearn.egg-info → combatlearn-2.3.0}/PKG-INFO +36 -6
- combatlearn-2.2.0/PKG-INFO → combatlearn-2.3.0/README.md +29 -42
- combatlearn-2.3.0/combatlearn/__init__.py +5 -0
- combatlearn-2.3.0/combatlearn/_nested.py +326 -0
- combatlearn-2.3.0/combatlearn/_utils.py +66 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/core.py +84 -98
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/inspection.py +52 -4
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/metrics.py +1 -1
- combatlearn-2.3.0/combatlearn/sklearn_api.py +777 -0
- combatlearn-2.3.0/combatlearn/transductive/__init__.py +15 -0
- combatlearn-2.3.0/combatlearn/transductive/_transductive.py +189 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/visualization.py +11 -10
- combatlearn-2.2.0/README.md → combatlearn-2.3.0/combatlearn.egg-info/PKG-INFO +72 -4
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/SOURCES.txt +6 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/requires.txt +1 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/pyproject.toml +8 -3
- {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements-dev.txt +1 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_combat.py +35 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_gam.py +19 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_inspection.py +33 -5
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_longitudinal.py +3 -2
- combatlearn-2.3.0/tests/test_nested.py +335 -0
- combatlearn-2.3.0/tests/test_property.py +297 -0
- combatlearn-2.3.0/tests/test_transductive.py +74 -0
- combatlearn-2.2.0/combatlearn/__init__.py +0 -5
- combatlearn-2.2.0/combatlearn/_utils.py +0 -21
- combatlearn-2.2.0/combatlearn/sklearn_api.py +0 -362
- {combatlearn-2.2.0 → combatlearn-2.3.0}/LICENSE +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/_mixed.py +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/py.typed +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/top_level.txt +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements-docs.txt +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements.txt +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/setup.cfg +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_eb_degenerate.py +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_metrics.py +0 -0
- {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_visualization.py +0 -0
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: combatlearn
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Batch-effect harmonization for machine learning frameworks.
|
|
5
5
|
Author-email: Ettore Rocchi <ettoreroc@gmail.com>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Keywords: machine-learning,harmonization,combat,preprocessing
|
|
8
|
-
Classifier: Development Status ::
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
9
|
Classifier: Intended Audience :: Science/Research
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
16
|
Requires-Python: >=3.10
|
|
13
17
|
Description-Content-Type: text/markdown
|
|
14
18
|
License-File: LICENSE
|
|
@@ -23,6 +27,7 @@ Requires-Dist: umap-learn>=0.5
|
|
|
23
27
|
Provides-Extra: dev
|
|
24
28
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
29
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
26
31
|
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
27
32
|
Requires-Dist: pre-commit>=3.0; extra == "dev"
|
|
28
33
|
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
@@ -50,14 +55,17 @@ Dynamic: license-file
|
|
|
50
55
|
|
|
51
56
|
**combatlearn** makes the popular _ComBat_ (and _CovBat_) batch-effect correction algorithm available for use into machine learning frameworks. It lets you harmonise high-dimensional data inside a scikit-learn `Pipeline`, so that cross-validation and grid-search automatically take batch structure into account, **without data leakage**.
|
|
52
57
|
|
|
53
|
-
**
|
|
58
|
+
**Inductive `ComBat` methods** (fit on train, apply to held-out data, cross-validation-safe):
|
|
54
59
|
- `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
|
|
55
60
|
- `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
|
|
56
61
|
- `method="chen"` - CovBat (Chen _et al._, 2022)
|
|
57
|
-
- `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
|
|
58
62
|
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
59
63
|
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
60
64
|
|
|
65
|
+
**Multiple batch variables** - `NestedComBat` harmonizes over several batch variables at once (e.g. site, scanner, protocol) with optional order optimization and Gaussian-mixture grouping (Nested / OPNested / GMM ComBat, Horng _et al._, 2022).
|
|
66
|
+
|
|
67
|
+
**Whole-cohort (transductive)** - `combatlearn.transductive.TransductiveComBat(method="longitudinal")` for repeated-measures / longitudinal designs (Beer _et al._, 2020), used as a one-shot `fit_transform`. (`ComBat(method="longitudinal")` still works but is deprecated in favor of this and will be removed in v3.0.0.)
|
|
68
|
+
|
|
61
69
|
## Installation
|
|
62
70
|
|
|
63
71
|
```bash
|
|
@@ -121,6 +129,28 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
|
|
|
121
129
|
|
|
122
130
|
For a full example of how to use **combatlearn** see the [notebook demo](https://github.com/EttoreRocchi/combatlearn/blob/main/docs/source/demo/combatlearn_demo.ipynb)
|
|
123
131
|
|
|
132
|
+
## Multiple batch variables (`NestedComBat`)
|
|
133
|
+
|
|
134
|
+
When more than one technical variable needs harmonizing, `NestedComBat` applies ComBat to each in sequence. It picks the harmonization order that minimizes the residual batch effect, and can optionally add a latent Gaussian-mixture grouping. It is inductive and fits inside a `Pipeline` just like `ComBat`.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
import pandas as pd
|
|
138
|
+
from combatlearn import NestedComBat
|
|
139
|
+
|
|
140
|
+
batch = pd.DataFrame({"site": site, "scanner": scanner}) # one column per batch variable
|
|
141
|
+
|
|
142
|
+
nested = NestedComBat(
|
|
143
|
+
batch=batch,
|
|
144
|
+
continuous_covariates=age,
|
|
145
|
+
discrete_covariates=diag,
|
|
146
|
+
method="fortin", # per-step engine: "fortin"/"chen"/"gam"/"covbat_gam"
|
|
147
|
+
optimize_order=True, # choose the order (OPNested)
|
|
148
|
+
gmm=None, # or "batch" (+GMM) / "covariate" (-GMM)
|
|
149
|
+
)
|
|
150
|
+
X_corrected = nested.fit_transform(X)
|
|
151
|
+
print("Harmonization order:", nested.order_)
|
|
152
|
+
```
|
|
153
|
+
|
|
124
154
|
## `ComBat` parameters
|
|
125
155
|
|
|
126
156
|
The following section provides a detailed explanation of all parameters available in the scikit-learn-compatible `ComBat` class. For complete API documentation, see the [API Reference](https://combatlearn.readthedocs.io/en/latest/api/).
|
|
@@ -139,7 +169,7 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
139
169
|
|
|
140
170
|
| Parameter | Type | Default | Description |
|
|
141
171
|
| --- | --- | --- | --- |
|
|
142
|
-
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)
|
|
172
|
+
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_); **deprecated**, use `combatlearn.transductive.TransductiveComBat`</li><li>`"gam"` - ComBat-GAM, nonlinear (spline) covariates (_Pomponio et al. 2020_)</li><li>`"covbat_gam"` - CovBat with the same nonlinear covariate modeling</li></ul> Case- and separator-insensitive literature aliases are also accepted (e.g. `"covbat"`, `"neurocombat"`, `"combat_gam"`). |
|
|
143
173
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
144
174
|
| `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
|
|
145
175
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
@@ -1,41 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: combatlearn
|
|
3
|
-
Version: 2.2.0
|
|
4
|
-
Summary: Batch-effect harmonization for machine learning frameworks.
|
|
5
|
-
Author-email: Ettore Rocchi <ettoreroc@gmail.com>
|
|
6
|
-
License: MIT
|
|
7
|
-
Keywords: machine-learning,harmonization,combat,preprocessing
|
|
8
|
-
Classifier: Development Status :: 3 - Alpha
|
|
9
|
-
Classifier: Intended Audience :: Science/Research
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Requires-Python: >=3.10
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
License-File: LICENSE
|
|
15
|
-
Requires-Dist: pandas>=1.3
|
|
16
|
-
Requires-Dist: numpy>=1.21
|
|
17
|
-
Requires-Dist: scikit-learn>=1.2
|
|
18
|
-
Requires-Dist: matplotlib>=3.4
|
|
19
|
-
Requires-Dist: seaborn>=0.12
|
|
20
|
-
Requires-Dist: plotly>=5.0
|
|
21
|
-
Requires-Dist: nbformat>=4.2
|
|
22
|
-
Requires-Dist: umap-learn>=0.5
|
|
23
|
-
Provides-Extra: dev
|
|
24
|
-
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
-
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
26
|
-
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
27
|
-
Requires-Dist: pre-commit>=3.0; extra == "dev"
|
|
28
|
-
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
29
|
-
Requires-Dist: pandas-stubs; extra == "dev"
|
|
30
|
-
Requires-Dist: statsmodels>=0.14; extra == "dev"
|
|
31
|
-
Provides-Extra: docs
|
|
32
|
-
Requires-Dist: sphinx>=7.0.0; extra == "docs"
|
|
33
|
-
Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
|
|
34
|
-
Requires-Dist: myst-nb>=1.1.0; extra == "docs"
|
|
35
|
-
Requires-Dist: sphinx-copybutton>=0.5.0; extra == "docs"
|
|
36
|
-
Requires-Dist: linkify-it-py>=2.0.0; extra == "docs"
|
|
37
|
-
Dynamic: license-file
|
|
38
|
-
|
|
39
1
|
# **combatlearn**
|
|
40
2
|
|
|
41
3
|
[](https://www.python.org/)
|
|
@@ -50,14 +12,17 @@ Dynamic: license-file
|
|
|
50
12
|
|
|
51
13
|
**combatlearn** makes the popular _ComBat_ (and _CovBat_) batch-effect correction algorithm available for use into machine learning frameworks. It lets you harmonise high-dimensional data inside a scikit-learn `Pipeline`, so that cross-validation and grid-search automatically take batch structure into account, **without data leakage**.
|
|
52
14
|
|
|
53
|
-
**
|
|
15
|
+
**Inductive `ComBat` methods** (fit on train, apply to held-out data, cross-validation-safe):
|
|
54
16
|
- `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
|
|
55
17
|
- `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
|
|
56
18
|
- `method="chen"` - CovBat (Chen _et al._, 2022)
|
|
57
|
-
- `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
|
|
58
19
|
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
59
20
|
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
60
21
|
|
|
22
|
+
**Multiple batch variables** - `NestedComBat` harmonizes over several batch variables at once (e.g. site, scanner, protocol) with optional order optimization and Gaussian-mixture grouping (Nested / OPNested / GMM ComBat, Horng _et al._, 2022).
|
|
23
|
+
|
|
24
|
+
**Whole-cohort (transductive)** - `combatlearn.transductive.TransductiveComBat(method="longitudinal")` for repeated-measures / longitudinal designs (Beer _et al._, 2020), used as a one-shot `fit_transform`. (`ComBat(method="longitudinal")` still works but is deprecated in favor of this and will be removed in v3.0.0.)
|
|
25
|
+
|
|
61
26
|
## Installation
|
|
62
27
|
|
|
63
28
|
```bash
|
|
@@ -121,6 +86,28 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
|
|
|
121
86
|
|
|
122
87
|
For a full example of how to use **combatlearn** see the [notebook demo](https://github.com/EttoreRocchi/combatlearn/blob/main/docs/source/demo/combatlearn_demo.ipynb)
|
|
123
88
|
|
|
89
|
+
## Multiple batch variables (`NestedComBat`)
|
|
90
|
+
|
|
91
|
+
When more than one technical variable needs harmonizing, `NestedComBat` applies ComBat to each in sequence. It picks the harmonization order that minimizes the residual batch effect, and can optionally add a latent Gaussian-mixture grouping. It is inductive and fits inside a `Pipeline` just like `ComBat`.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import pandas as pd
|
|
95
|
+
from combatlearn import NestedComBat
|
|
96
|
+
|
|
97
|
+
batch = pd.DataFrame({"site": site, "scanner": scanner}) # one column per batch variable
|
|
98
|
+
|
|
99
|
+
nested = NestedComBat(
|
|
100
|
+
batch=batch,
|
|
101
|
+
continuous_covariates=age,
|
|
102
|
+
discrete_covariates=diag,
|
|
103
|
+
method="fortin", # per-step engine: "fortin"/"chen"/"gam"/"covbat_gam"
|
|
104
|
+
optimize_order=True, # choose the order (OPNested)
|
|
105
|
+
gmm=None, # or "batch" (+GMM) / "covariate" (-GMM)
|
|
106
|
+
)
|
|
107
|
+
X_corrected = nested.fit_transform(X)
|
|
108
|
+
print("Harmonization order:", nested.order_)
|
|
109
|
+
```
|
|
110
|
+
|
|
124
111
|
## `ComBat` parameters
|
|
125
112
|
|
|
126
113
|
The following section provides a detailed explanation of all parameters available in the scikit-learn-compatible `ComBat` class. For complete API documentation, see the [API Reference](https://combatlearn.readthedocs.io/en/latest/api/).
|
|
@@ -139,7 +126,7 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
139
126
|
|
|
140
127
|
| Parameter | Type | Default | Description |
|
|
141
128
|
| --- | --- | --- | --- |
|
|
142
|
-
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)
|
|
129
|
+
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_); **deprecated**, use `combatlearn.transductive.TransductiveComBat`</li><li>`"gam"` - ComBat-GAM, nonlinear (spline) covariates (_Pomponio et al. 2020_)</li><li>`"covbat_gam"` - CovBat with the same nonlinear covariate modeling</li></ul> Case- and separator-insensitive literature aliases are also accepted (e.g. `"covbat"`, `"neurocombat"`, `"combat_gam"`). |
|
|
143
130
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
144
131
|
| `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
|
|
145
132
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
@@ -204,4 +191,4 @@ Please consider citing the original papers:
|
|
|
204
191
|
|
|
205
192
|
- [**Longitudinal ComBat**](https://github.com/jcbeer/longCombat) - Beer JC et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2020.117129](https://doi.org/10.1016/j.neuroimage.2020.117129)
|
|
206
193
|
|
|
207
|
-
- [**ComBat-GAM**](https://github.com/rpomponio/neuroHarmonize) - Pomponio R et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2019.116450](https://doi.org/10.1016/j.neuroimage.2019.116450)
|
|
194
|
+
- [**ComBat-GAM**](https://github.com/rpomponio/neuroHarmonize) - Pomponio R et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2019.116450](https://doi.org/10.1016/j.neuroimage.2019.116450)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Orchestration helpers for NestedComBat (*Horng et al.* 2022).
|
|
2
|
+
|
|
3
|
+
Nested ComBat harmonizes several batch variables (e.g. site, scanner, protocol)
|
|
4
|
+
by applying single-batch ComBat to each one in sequence, every step delegating
|
|
5
|
+
to a :class:`~combatlearn.core.ComBatModel`. This module holds the pieces that
|
|
6
|
+
surround that sequence and add no new empirical-Bayes math:
|
|
7
|
+
|
|
8
|
+
* :func:`fit_nested_sequence` - fit one ``ComBatModel`` per batch variable in a
|
|
9
|
+
given order, chaining the transforms.
|
|
10
|
+
* :func:`anderson_darling_batch_count` - the residual-batch-effect objective the
|
|
11
|
+
OPNested order search minimizes (Anderson-Darling k-sample feature count).
|
|
12
|
+
* :func:`search_order` - exhaustive order search over all ``k!`` orderings, with
|
|
13
|
+
a factorial guardrail that falls back to greedy forward selection.
|
|
14
|
+
* :class:`GMMGrouping` - the per-feature Gaussian-mixture grouping fed back into
|
|
15
|
+
the sequence as an extra batch variable (``+GMM``) or covariate (``-GMM``).
|
|
16
|
+
|
|
17
|
+
Everything here is learned on the training data and frozen for transform, so the
|
|
18
|
+
resulting estimator stays inductive / cross-validation-safe.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import warnings
|
|
24
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
25
|
+
from itertools import permutations
|
|
26
|
+
from math import factorial
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
from scipy.stats import anderson_ksamp
|
|
31
|
+
from sklearn.mixture import GaussianMixture
|
|
32
|
+
|
|
33
|
+
from .core import ComBatModel
|
|
34
|
+
|
|
35
|
+
GMM_VAR_NAME = "GMM"
|
|
36
|
+
|
|
37
|
+
ModelStep = tuple[str, ComBatModel]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def variance_explained_by_batch(X: pd.DataFrame, batch: pd.Series) -> float:
|
|
41
|
+
"""Fraction of total variance explained by ``batch`` (summed across features)."""
|
|
42
|
+
values = X.to_numpy(dtype=np.float64)
|
|
43
|
+
labels = np.asarray(batch)
|
|
44
|
+
grand = values.mean(axis=0)
|
|
45
|
+
ss_total = float(((values - grand) ** 2).sum())
|
|
46
|
+
if ss_total == 0.0:
|
|
47
|
+
return 0.0
|
|
48
|
+
ss_between = 0.0
|
|
49
|
+
for lvl in pd.unique(labels):
|
|
50
|
+
mask = labels == lvl
|
|
51
|
+
batch_mean = values[mask].mean(axis=0)
|
|
52
|
+
ss_between += float(mask.sum()) * float(((batch_mean - grand) ** 2).sum())
|
|
53
|
+
return ss_between / ss_total
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def anderson_darling_batch_count(
|
|
57
|
+
X: pd.DataFrame,
|
|
58
|
+
batch_vars: Mapping[str, pd.Series],
|
|
59
|
+
*,
|
|
60
|
+
alpha: float = 0.05,
|
|
61
|
+
) -> int:
|
|
62
|
+
"""Number of features with a significant residual batch effect.
|
|
63
|
+
|
|
64
|
+
For every batch variable and every feature the Anderson-Darling k-sample test
|
|
65
|
+
(:func:`scipy.stats.anderson_ksamp`) compares the feature distribution across
|
|
66
|
+
that variable's levels; a feature is counted once per batch variable whose
|
|
67
|
+
test is significant at ``alpha``. The counts are summed over all batch
|
|
68
|
+
variables, so a lower value means less residual batch structure. This is the
|
|
69
|
+
objective minimized by the OPNested order search.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
X : pd.DataFrame of shape (n_samples, n_features)
|
|
74
|
+
Harmonized data to score.
|
|
75
|
+
batch_vars : mapping of str to pd.Series
|
|
76
|
+
Batch variables, each a per-sample label vector.
|
|
77
|
+
alpha : float, default=0.05
|
|
78
|
+
Significance threshold for the Anderson-Darling test.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
int
|
|
83
|
+
Total number of significant (feature, batch variable) pairs.
|
|
84
|
+
"""
|
|
85
|
+
col_values = X.to_numpy(dtype=np.float64)
|
|
86
|
+
n_features = col_values.shape[1]
|
|
87
|
+
total = 0
|
|
88
|
+
with warnings.catch_warnings():
|
|
89
|
+
# anderson_ksamp warns when the p-value is capped to [0.001, 0.25]; that
|
|
90
|
+
# capping never changes the alpha=0.05 decision, so the warning is considered noise.
|
|
91
|
+
warnings.simplefilter("ignore")
|
|
92
|
+
for batch in batch_vars.values():
|
|
93
|
+
labels = np.asarray(batch)
|
|
94
|
+
masks = [labels == lvl for lvl in pd.unique(labels)]
|
|
95
|
+
masks = [m for m in masks if m.any()]
|
|
96
|
+
if len(masks) < 2:
|
|
97
|
+
continue
|
|
98
|
+
for j in range(n_features):
|
|
99
|
+
col = col_values[:, j]
|
|
100
|
+
samples = [col[m] for m in masks]
|
|
101
|
+
try:
|
|
102
|
+
result = anderson_ksamp(samples)
|
|
103
|
+
except ValueError:
|
|
104
|
+
# Degenerate feature (e.g. constant within the pooled sample):
|
|
105
|
+
# no detectable batch effect, so it does not count.
|
|
106
|
+
continue
|
|
107
|
+
if result.significance_level < alpha:
|
|
108
|
+
total += 1
|
|
109
|
+
return total
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fit_nested_sequence(
|
|
113
|
+
order: Sequence[str],
|
|
114
|
+
X: pd.DataFrame,
|
|
115
|
+
batch_vars: Mapping[str, pd.Series],
|
|
116
|
+
disc: pd.DataFrame | None,
|
|
117
|
+
cont: pd.DataFrame | None,
|
|
118
|
+
make_model: Callable[[str], ComBatModel],
|
|
119
|
+
) -> tuple[list[ModelStep], pd.DataFrame]:
|
|
120
|
+
"""Fit one ``ComBatModel`` per batch variable in ``order``, chaining transforms.
|
|
121
|
+
|
|
122
|
+
The covariates ``disc`` / ``cont`` are preserved across every step, so nesting
|
|
123
|
+
composes with the covariate-aware, CovBat, and GAM engines.
|
|
124
|
+
|
|
125
|
+
Returns the list of ``(batch_variable, fitted_model)`` steps and the fully
|
|
126
|
+
harmonized training data.
|
|
127
|
+
"""
|
|
128
|
+
models: list[ModelStep] = []
|
|
129
|
+
X_current = X
|
|
130
|
+
for name in order:
|
|
131
|
+
model = make_model(name)
|
|
132
|
+
batch = batch_vars[name]
|
|
133
|
+
model.fit(X_current, batch=batch, discrete_covariates=disc, continuous_covariates=cont)
|
|
134
|
+
X_current = model.transform(
|
|
135
|
+
X_current, batch=batch, discrete_covariates=disc, continuous_covariates=cont
|
|
136
|
+
)
|
|
137
|
+
models.append((name, model))
|
|
138
|
+
return models, X_current
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def transform_nested_sequence(
|
|
142
|
+
models: Sequence[ModelStep],
|
|
143
|
+
X: pd.DataFrame,
|
|
144
|
+
batch_vars: Mapping[str, pd.Series],
|
|
145
|
+
disc: pd.DataFrame | None,
|
|
146
|
+
cont: pd.DataFrame | None,
|
|
147
|
+
) -> pd.DataFrame:
|
|
148
|
+
"""Replay a fitted nested sequence on new data, in the stored order."""
|
|
149
|
+
X_current = X
|
|
150
|
+
for name, model in models:
|
|
151
|
+
batch = batch_vars[name]
|
|
152
|
+
X_current = model.transform(
|
|
153
|
+
X_current, batch=batch, discrete_covariates=disc, continuous_covariates=cont
|
|
154
|
+
)
|
|
155
|
+
return X_current
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _search_exhaustive(
|
|
159
|
+
names: Sequence[str],
|
|
160
|
+
X: pd.DataFrame,
|
|
161
|
+
batch_vars: Mapping[str, pd.Series],
|
|
162
|
+
disc: pd.DataFrame | None,
|
|
163
|
+
cont: pd.DataFrame | None,
|
|
164
|
+
make_model: Callable[[str], ComBatModel],
|
|
165
|
+
alpha: float,
|
|
166
|
+
) -> tuple[list[str], list[ModelStep]]:
|
|
167
|
+
"""Try every ordering, keeping the first that minimizes the AD feature count."""
|
|
168
|
+
best_score: int | None = None
|
|
169
|
+
best_order: list[str] = list(names)
|
|
170
|
+
best_models: list[ModelStep] = []
|
|
171
|
+
for order in permutations(names):
|
|
172
|
+
models, X_harmonized = fit_nested_sequence(order, X, batch_vars, disc, cont, make_model)
|
|
173
|
+
score = anderson_darling_batch_count(X_harmonized, batch_vars, alpha=alpha)
|
|
174
|
+
if best_score is None or score < best_score:
|
|
175
|
+
best_score = score
|
|
176
|
+
best_order = list(order)
|
|
177
|
+
best_models = models
|
|
178
|
+
return best_order, best_models
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _search_greedy(
|
|
182
|
+
names: Sequence[str],
|
|
183
|
+
X: pd.DataFrame,
|
|
184
|
+
batch_vars: Mapping[str, pd.Series],
|
|
185
|
+
disc: pd.DataFrame | None,
|
|
186
|
+
cont: pd.DataFrame | None,
|
|
187
|
+
make_model: Callable[[str], ComBatModel],
|
|
188
|
+
alpha: float,
|
|
189
|
+
) -> tuple[list[str], list[ModelStep]]:
|
|
190
|
+
"""Greedy forward selection: at each position pick the step that most reduces
|
|
191
|
+
the AD feature count, harmonize with it, then continue with the rest."""
|
|
192
|
+
remaining = list(names)
|
|
193
|
+
order: list[str] = []
|
|
194
|
+
models: list[ModelStep] = []
|
|
195
|
+
X_current = X
|
|
196
|
+
while remaining:
|
|
197
|
+
best_score: int | None = None
|
|
198
|
+
best_name = remaining[0]
|
|
199
|
+
best_model: ComBatModel | None = None
|
|
200
|
+
best_X = X_current
|
|
201
|
+
for name in remaining:
|
|
202
|
+
model = make_model(name)
|
|
203
|
+
batch = batch_vars[name]
|
|
204
|
+
model.fit(X_current, batch=batch, discrete_covariates=disc, continuous_covariates=cont)
|
|
205
|
+
X_try = model.transform(
|
|
206
|
+
X_current, batch=batch, discrete_covariates=disc, continuous_covariates=cont
|
|
207
|
+
)
|
|
208
|
+
score = anderson_darling_batch_count(X_try, batch_vars, alpha=alpha)
|
|
209
|
+
if best_score is None or score < best_score:
|
|
210
|
+
best_score = score
|
|
211
|
+
best_name = name
|
|
212
|
+
best_model = model
|
|
213
|
+
best_X = X_try
|
|
214
|
+
assert best_model is not None
|
|
215
|
+
order.append(best_name)
|
|
216
|
+
models.append((best_name, best_model))
|
|
217
|
+
X_current = best_X
|
|
218
|
+
remaining.remove(best_name)
|
|
219
|
+
return order, models
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def search_order(
|
|
223
|
+
names: Sequence[str],
|
|
224
|
+
X: pd.DataFrame,
|
|
225
|
+
batch_vars: Mapping[str, pd.Series],
|
|
226
|
+
disc: pd.DataFrame | None,
|
|
227
|
+
cont: pd.DataFrame | None,
|
|
228
|
+
make_model: Callable[[str], ComBatModel],
|
|
229
|
+
*,
|
|
230
|
+
max_exhaustive_vars: int,
|
|
231
|
+
alpha: float = 0.05,
|
|
232
|
+
) -> tuple[list[str], list[ModelStep], bool]:
|
|
233
|
+
"""Select a harmonization order that minimizes the residual batch effect.
|
|
234
|
+
|
|
235
|
+
All ``k!`` orderings are tried exhaustively when ``k <= max_exhaustive_vars``;
|
|
236
|
+
above the cap the factorial cost is prohibitive, so a warning is issued and
|
|
237
|
+
the search falls back to greedy forward selection. A large exhaustive search
|
|
238
|
+
(``k >= 4``) also warns up front with the number of ComBat fits it will run.
|
|
239
|
+
|
|
240
|
+
Returns the chosen order, the fitted sequence for that order, and whether the
|
|
241
|
+
greedy fallback was used.
|
|
242
|
+
"""
|
|
243
|
+
k = len(names)
|
|
244
|
+
if k <= max_exhaustive_vars:
|
|
245
|
+
if k >= 4:
|
|
246
|
+
n_orderings = factorial(k)
|
|
247
|
+
warnings.warn(
|
|
248
|
+
f"OPNested exhaustive order search over {k} batch variables will fit "
|
|
249
|
+
f"{n_orderings} orderings x {k} steps = {n_orderings * k} ComBat models, each "
|
|
250
|
+
f"scored with a per-feature Anderson-Darling test; on high-dimensional data "
|
|
251
|
+
f"this can be slow. Lower max_exhaustive_vars to use the greedy search instead, "
|
|
252
|
+
f"or set optimize_order=False to skip the search.",
|
|
253
|
+
UserWarning,
|
|
254
|
+
stacklevel=2,
|
|
255
|
+
)
|
|
256
|
+
order, models = _search_exhaustive(names, X, batch_vars, disc, cont, make_model, alpha)
|
|
257
|
+
return order, models, False
|
|
258
|
+
warnings.warn(
|
|
259
|
+
f"{len(names)} batch variables exceed max_exhaustive_vars="
|
|
260
|
+
f"{max_exhaustive_vars}; an exhaustive search over {len(names)}! orderings is "
|
|
261
|
+
f"impractical, so falling back to greedy forward selection. Raise "
|
|
262
|
+
f"max_exhaustive_vars to force the exhaustive search.",
|
|
263
|
+
UserWarning,
|
|
264
|
+
stacklevel=2,
|
|
265
|
+
)
|
|
266
|
+
order, models = _search_greedy(names, X, batch_vars, disc, cont, make_model, alpha)
|
|
267
|
+
return order, models, True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class GMMGrouping:
|
|
271
|
+
"""A per-feature Gaussian-mixture grouping (*Horng et al.* 2022, GMM ComBat).
|
|
272
|
+
|
|
273
|
+
A 2-component Gaussian mixture is fit to each feature; features whose split
|
|
274
|
+
leaves either cluster below ``min_cluster_frac`` of the samples are discarded
|
|
275
|
+
(an unbalanced split rarely reflects a real latent group), and the balanced
|
|
276
|
+
feature with the lowest AIC is retained. Its two-component assignment is the
|
|
277
|
+
latent grouping fed back into NestedComBat as an extra batch variable
|
|
278
|
+
(``+GMM``) or a protected covariate (``-GMM``).
|
|
279
|
+
|
|
280
|
+
The mixture is fit on the training data and stored, so held-out samples are
|
|
281
|
+
assigned by :meth:`assign` (``predict`` on the same fitted mixture), keeping
|
|
282
|
+
the grouping leakage-free.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, feature: object, gmm: GaussianMixture) -> None:
|
|
286
|
+
self.feature = feature
|
|
287
|
+
self.gmm = gmm
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def fit(
|
|
291
|
+
cls,
|
|
292
|
+
X: pd.DataFrame,
|
|
293
|
+
*,
|
|
294
|
+
min_cluster_frac: float = 0.25,
|
|
295
|
+
random_state: int | None = 0,
|
|
296
|
+
) -> GMMGrouping | None:
|
|
297
|
+
"""Fit the grouping, or return ``None`` if no feature yields a balanced split."""
|
|
298
|
+
n_samples = len(X)
|
|
299
|
+
best_feature: object = None
|
|
300
|
+
best_aic = np.inf
|
|
301
|
+
best_gmm: GaussianMixture | None = None
|
|
302
|
+
for col in X.columns:
|
|
303
|
+
x = X[col].to_numpy(dtype=np.float64).reshape(-1, 1)
|
|
304
|
+
gmm = GaussianMixture(n_components=2, random_state=random_state)
|
|
305
|
+
try:
|
|
306
|
+
gmm.fit(x)
|
|
307
|
+
labels = gmm.predict(x)
|
|
308
|
+
except ValueError:
|
|
309
|
+
continue
|
|
310
|
+
smaller = min(int((labels == 0).sum()), int((labels == 1).sum()))
|
|
311
|
+
if smaller <= min_cluster_frac * n_samples:
|
|
312
|
+
continue
|
|
313
|
+
aic = float(gmm.aic(x))
|
|
314
|
+
if aic < best_aic:
|
|
315
|
+
best_aic = aic
|
|
316
|
+
best_feature = col
|
|
317
|
+
best_gmm = gmm
|
|
318
|
+
if best_gmm is None:
|
|
319
|
+
return None
|
|
320
|
+
return cls(best_feature, best_gmm)
|
|
321
|
+
|
|
322
|
+
def assign(self, X: pd.DataFrame) -> pd.Series:
|
|
323
|
+
"""Assign each sample to its mixture component (``'gmm0'`` / ``'gmm1'``)."""
|
|
324
|
+
x = X[self.feature].to_numpy(dtype=np.float64).reshape(-1, 1)
|
|
325
|
+
labels = self.gmm.predict(x)
|
|
326
|
+
return pd.Series([f"gmm{int(c)}" for c in labels], index=X.index, name=GMM_VAR_NAME)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Internal utilities shared across combatlearn modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .core import ArrayLike
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check_positional_alignment(
|
|
14
|
+
X: ArrayLike, auxiliaries: Iterable[tuple[str, ArrayLike | None]]
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Require length-matched auxiliaries when ``X`` carries no index labels.
|
|
17
|
+
|
|
18
|
+
``batch`` and the covariates are supplied at construction and label-aligned to
|
|
19
|
+
``X`` via its pandas index. When ``X`` is a plain array it has no labels, so the
|
|
20
|
+
only possible alignment is positional, which needs equal lengths. This most
|
|
21
|
+
often breaks inside cross-validation: the CV splitter subsets ``X`` positionally
|
|
22
|
+
while the construction-time ``batch``/covariates stay full-length, and the
|
|
23
|
+
silent length mismatch would otherwise surface as a confusing pandas reindexing
|
|
24
|
+
error. A DataFrame ``X`` label-aligns and is skipped.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
X : array-like
|
|
29
|
+
The data passed to ``fit``/``transform``.
|
|
30
|
+
auxiliaries : iterable of (name, object)
|
|
31
|
+
The construction-time vectors to check; ``None`` entries are ignored.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
ValueError
|
|
36
|
+
If ``X`` is not a DataFrame and any auxiliary has a different length.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(X, pd.DataFrame):
|
|
39
|
+
return
|
|
40
|
+
n = len(X)
|
|
41
|
+
for name, obj in auxiliaries:
|
|
42
|
+
if obj is None:
|
|
43
|
+
continue
|
|
44
|
+
if len(obj) != n:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"`{name}` has length {len(obj)} but X has {n} rows. When X is not a "
|
|
47
|
+
f"pandas DataFrame it has no index labels, so combatlearn can only align "
|
|
48
|
+
f"`{name}` to X positionally, which requires equal lengths. This typically "
|
|
49
|
+
f"happens inside cross-validation, where the splitter subsets X but the "
|
|
50
|
+
f"batch/covariates passed at construction stay full-length. Pass X and "
|
|
51
|
+
f"`{name}` as pandas objects sharing the same index so folds stay "
|
|
52
|
+
f"label-aligned, or pass a same-length `{name}`."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
|
|
57
|
+
"""Subset array-like object by index."""
|
|
58
|
+
if obj is None:
|
|
59
|
+
return None
|
|
60
|
+
if isinstance(obj, pd.Series | pd.DataFrame):
|
|
61
|
+
return obj.loc[idx]
|
|
62
|
+
else:
|
|
63
|
+
if isinstance(obj, np.ndarray) and obj.ndim == 1:
|
|
64
|
+
return pd.Series(obj, index=idx)
|
|
65
|
+
else:
|
|
66
|
+
return pd.DataFrame(obj, index=idx)
|