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.
Files changed (38) hide show
  1. {combatlearn-2.1.0 → combatlearn-2.3.0}/PKG-INFO +46 -8
  2. {combatlearn-2.1.0 → combatlearn-2.3.0}/README.md +39 -6
  3. combatlearn-2.3.0/combatlearn/__init__.py +5 -0
  4. combatlearn-2.3.0/combatlearn/_nested.py +326 -0
  5. combatlearn-2.3.0/combatlearn/_utils.py +66 -0
  6. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/core.py +375 -117
  7. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/inspection.py +52 -4
  8. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/metrics.py +1 -1
  9. combatlearn-2.3.0/combatlearn/sklearn_api.py +777 -0
  10. combatlearn-2.3.0/combatlearn/transductive/__init__.py +15 -0
  11. combatlearn-2.3.0/combatlearn/transductive/_transductive.py +189 -0
  12. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/visualization.py +116 -59
  13. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/PKG-INFO +46 -8
  14. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/SOURCES.txt +8 -0
  15. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/requires.txt +1 -0
  16. {combatlearn-2.1.0 → combatlearn-2.3.0}/pyproject.toml +8 -3
  17. {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements-dev.txt +1 -0
  18. {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_combat.py +81 -0
  19. combatlearn-2.3.0/tests/test_eb_degenerate.py +111 -0
  20. combatlearn-2.3.0/tests/test_gam.py +317 -0
  21. {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_inspection.py +33 -5
  22. {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_longitudinal.py +15 -2
  23. combatlearn-2.3.0/tests/test_nested.py +335 -0
  24. combatlearn-2.3.0/tests/test_property.py +297 -0
  25. combatlearn-2.3.0/tests/test_transductive.py +74 -0
  26. {combatlearn-2.1.0 → combatlearn-2.3.0}/tests/test_visualization.py +35 -0
  27. combatlearn-2.1.0/combatlearn/__init__.py +0 -5
  28. combatlearn-2.1.0/combatlearn/_utils.py +0 -21
  29. combatlearn-2.1.0/combatlearn/sklearn_api.py +0 -331
  30. {combatlearn-2.1.0 → combatlearn-2.3.0}/LICENSE +0 -0
  31. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/_mixed.py +0 -0
  32. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn/py.typed +0 -0
  33. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  34. {combatlearn-2.1.0 → combatlearn-2.3.0}/combatlearn.egg-info/top_level.txt +0 -0
  35. {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements-docs.txt +0 -0
  36. {combatlearn-2.1.0 → combatlearn-2.3.0}/requirements.txt +0 -0
  37. {combatlearn-2.1.0 → combatlearn-2.3.0}/setup.cfg +0 -0
  38. {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.1.0
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 :: 3 - Alpha
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
- **Four methods**:
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)
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 `"longitudinal"` methods. |
132
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
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
- **Four methods**:
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="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
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 `"longitudinal"` methods. |
94
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
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,5 @@
1
+ from .sklearn_api import ComBat, NestedComBat
2
+
3
+ __all__ = ["ComBat", "NestedComBat"]
4
+ __version__ = "2.3.0"
5
+ __author__ = "Ettore Rocchi"
@@ -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)