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.
Files changed (38) hide show
  1. {combatlearn-2.2.0/combatlearn.egg-info → combatlearn-2.3.0}/PKG-INFO +36 -6
  2. combatlearn-2.2.0/PKG-INFO → combatlearn-2.3.0/README.md +29 -42
  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.2.0 → combatlearn-2.3.0}/combatlearn/core.py +84 -98
  7. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/inspection.py +52 -4
  8. {combatlearn-2.2.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.2.0 → combatlearn-2.3.0}/combatlearn/visualization.py +11 -10
  13. combatlearn-2.2.0/README.md → combatlearn-2.3.0/combatlearn.egg-info/PKG-INFO +72 -4
  14. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/SOURCES.txt +6 -0
  15. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/requires.txt +1 -0
  16. {combatlearn-2.2.0 → combatlearn-2.3.0}/pyproject.toml +8 -3
  17. {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements-dev.txt +1 -0
  18. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_combat.py +35 -0
  19. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_gam.py +19 -0
  20. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_inspection.py +33 -5
  21. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_longitudinal.py +3 -2
  22. combatlearn-2.3.0/tests/test_nested.py +335 -0
  23. combatlearn-2.3.0/tests/test_property.py +297 -0
  24. combatlearn-2.3.0/tests/test_transductive.py +74 -0
  25. combatlearn-2.2.0/combatlearn/__init__.py +0 -5
  26. combatlearn-2.2.0/combatlearn/_utils.py +0 -21
  27. combatlearn-2.2.0/combatlearn/sklearn_api.py +0 -362
  28. {combatlearn-2.2.0 → combatlearn-2.3.0}/LICENSE +0 -0
  29. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/_mixed.py +0 -0
  30. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn/py.typed +0 -0
  31. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  32. {combatlearn-2.2.0 → combatlearn-2.3.0}/combatlearn.egg-info/top_level.txt +0 -0
  33. {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements-docs.txt +0 -0
  34. {combatlearn-2.2.0 → combatlearn-2.3.0}/requirements.txt +0 -0
  35. {combatlearn-2.2.0 → combatlearn-2.3.0}/setup.cfg +0 -0
  36. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_eb_degenerate.py +0 -0
  37. {combatlearn-2.2.0 → combatlearn-2.3.0}/tests/test_metrics.py +0 -0
  38. {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.2.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,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
- **Six 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)
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_)</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"`). |
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
  [![Python versions](https://img.shields.io/badge/python-%3E%3D3.10-blue?logo=python)](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
- **Six methods**:
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_)</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"`). |
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,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)