combatlearn 2.1.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.1.0 → combatlearn-2.3.0}/PKG-INFO +46 -8
- {combatlearn-2.1.0 → combatlearn-2.3.0}/README.md +39 -6
- 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.1.0 → combatlearn-2.3.0}/combatlearn/core.py +375 -117
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/inspection.py +52 -4
- {combatlearn-2.1.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.1.0 → combatlearn-2.3.0}/combatlearn/visualization.py +116 -59
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/PKG-INFO +46 -8
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/SOURCES.txt +8 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/requires.txt +1 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/pyproject.toml +8 -3
- {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements-dev.txt +1 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_combat.py +81 -0
- combatlearn-2.3.0/tests/test_eb_degenerate.py +111 -0
- combatlearn-2.3.0/tests/test_gam.py +317 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_inspection.py +33 -5
- {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_longitudinal.py +15 -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.1.0 → combatlearn-2.3.0}/tests/test_visualization.py +35 -0
- combatlearn-2.1.0/combatlearn/__init__.py +0 -5
- combatlearn-2.1.0/combatlearn/_utils.py +0 -21
- combatlearn-2.1.0/combatlearn/sklearn_api.py +0 -331
- {combatlearn-2.1.0 → combatlearn-2.3.0}/LICENSE +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/_mixed.py +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/py.typed +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/top_level.txt +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements-docs.txt +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements.txt +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/setup.cfg +0 -0
- {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_metrics.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,11 +55,16 @@ 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="
|
|
62
|
+
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
63
|
+
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
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.)
|
|
58
68
|
|
|
59
69
|
## Installation
|
|
60
70
|
|
|
@@ -119,6 +129,28 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
|
|
|
119
129
|
|
|
120
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)
|
|
121
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
|
+
|
|
122
154
|
## `ComBat` parameters
|
|
123
155
|
|
|
124
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/).
|
|
@@ -128,8 +160,8 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
128
160
|
| Parameter | Type | Default | Description |
|
|
129
161
|
| --- | --- | --- | --- |
|
|
130
162
|
| `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
|
|
131
|
-
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"
|
|
132
|
-
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"
|
|
163
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. |
|
|
164
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. **Required** for the GAM methods. |
|
|
133
165
|
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
134
166
|
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
135
167
|
|
|
@@ -137,11 +169,15 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
137
169
|
|
|
138
170
|
| Parameter | Type | Default | Description |
|
|
139
171
|
| --- | --- | --- | --- |
|
|
140
|
-
| `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_)</li></ul> |
|
|
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"`). |
|
|
141
173
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
142
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. |
|
|
143
175
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
144
176
|
| `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold in `(0, 1]` to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
|
|
177
|
+
| `smooth_terms` | list of str or int, or `None` | `None` | For `"gam"`/`"covbat_gam"` only: continuous covariates to model nonlinearly with B-splines, by column name or integer position. `None` smooths all continuous covariates. |
|
|
178
|
+
| `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
|
|
179
|
+
| `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
|
|
180
|
+
| `smooth_term_bounds` | tuple or dict | `None` | For the GAM methods: boundary knots, as a single `(lo, hi)` for all terms or a `{term: (lo, hi)}` dict. Default uses each term's training min/max; widen to cover held-out data. |
|
|
145
181
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
146
182
|
|
|
147
183
|
|
|
@@ -197,3 +233,5 @@ Please consider citing the original papers:
|
|
|
197
233
|
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
198
234
|
|
|
199
235
|
- [**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)
|
|
236
|
+
|
|
237
|
+
- [**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)
|
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
|
|
13
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**.
|
|
14
14
|
|
|
15
|
-
**
|
|
15
|
+
**Inductive `ComBat` methods** (fit on train, apply to held-out data, cross-validation-safe):
|
|
16
16
|
- `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
|
|
17
17
|
- `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
|
|
18
18
|
- `method="chen"` - CovBat (Chen _et al._, 2022)
|
|
19
|
-
- `method="
|
|
19
|
+
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
20
|
+
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
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.)
|
|
20
25
|
|
|
21
26
|
## Installation
|
|
22
27
|
|
|
@@ -81,6 +86,28 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
|
|
|
81
86
|
|
|
82
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)
|
|
83
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
|
+
|
|
84
111
|
## `ComBat` parameters
|
|
85
112
|
|
|
86
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/).
|
|
@@ -90,8 +117,8 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
90
117
|
| Parameter | Type | Default | Description |
|
|
91
118
|
| --- | --- | --- | --- |
|
|
92
119
|
| `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
|
|
93
|
-
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"
|
|
94
|
-
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"
|
|
120
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. |
|
|
121
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. **Required** for the GAM methods. |
|
|
95
122
|
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
96
123
|
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
97
124
|
|
|
@@ -99,11 +126,15 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
99
126
|
|
|
100
127
|
| Parameter | Type | Default | Description |
|
|
101
128
|
| --- | --- | --- | --- |
|
|
102
|
-
| `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_)</li></ul> |
|
|
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"`). |
|
|
103
130
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
104
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. |
|
|
105
132
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
106
133
|
| `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold in `(0, 1]` to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
|
|
134
|
+
| `smooth_terms` | list of str or int, or `None` | `None` | For `"gam"`/`"covbat_gam"` only: continuous covariates to model nonlinearly with B-splines, by column name or integer position. `None` smooths all continuous covariates. |
|
|
135
|
+
| `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
|
|
136
|
+
| `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
|
|
137
|
+
| `smooth_term_bounds` | tuple or dict | `None` | For the GAM methods: boundary knots, as a single `(lo, hi)` for all terms or a `{term: (lo, hi)}` dict. Default uses each term's training min/max; widen to cover held-out data. |
|
|
107
138
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
108
139
|
|
|
109
140
|
|
|
@@ -158,4 +189,6 @@ Please consider citing the original papers:
|
|
|
158
189
|
|
|
159
190
|
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
160
191
|
|
|
161
|
-
- [**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)
|
|
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)
|
|
193
|
+
|
|
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)
|