combatlearn 2.2.0__tar.gz → 2.4.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 (47) hide show
  1. {combatlearn-2.2.0 → combatlearn-2.4.0}/PKG-INFO +98 -10
  2. {combatlearn-2.2.0 → combatlearn-2.4.0}/README.md +89 -8
  3. combatlearn-2.4.0/combatlearn/__init__.py +6 -0
  4. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/_mixed.py +6 -22
  5. combatlearn-2.4.0/combatlearn/_nested.py +291 -0
  6. combatlearn-2.4.0/combatlearn/_utils.py +122 -0
  7. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/core.py +274 -286
  8. combatlearn-2.4.0/combatlearn/glm/__init__.py +10 -0
  9. combatlearn-2.4.0/combatlearn/glm/_beta.py +281 -0
  10. combatlearn-2.4.0/combatlearn/glm/_estimator.py +246 -0
  11. combatlearn-2.4.0/combatlearn/glm/_interpolate.py +137 -0
  12. combatlearn-2.4.0/combatlearn/glm/_model.py +623 -0
  13. combatlearn-2.4.0/combatlearn/glm/_nb.py +480 -0
  14. combatlearn-2.4.0/combatlearn/glm/_quantile.py +93 -0
  15. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/inspection.py +61 -23
  16. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/metrics.py +22 -229
  17. combatlearn-2.4.0/combatlearn/sklearn_api.py +986 -0
  18. combatlearn-2.4.0/combatlearn/transductive/__init__.py +13 -0
  19. combatlearn-2.4.0/combatlearn/transductive/_transductive.py +301 -0
  20. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/visualization.py +36 -28
  21. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn.egg-info/PKG-INFO +98 -10
  22. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn.egg-info/SOURCES.txt +14 -0
  23. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn.egg-info/requires.txt +3 -0
  24. {combatlearn-2.2.0 → combatlearn-2.4.0}/pyproject.toml +8 -3
  25. {combatlearn-2.2.0 → combatlearn-2.4.0}/requirements-dev.txt +2 -0
  26. {combatlearn-2.2.0 → combatlearn-2.4.0}/requirements.txt +1 -0
  27. combatlearn-2.4.0/tests/test_combat.py +967 -0
  28. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_eb_degenerate.py +10 -13
  29. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_gam.py +93 -25
  30. combatlearn-2.4.0/tests/test_glm.py +539 -0
  31. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_inspection.py +33 -6
  32. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_longitudinal.py +146 -4
  33. combatlearn-2.4.0/tests/test_nested.py +392 -0
  34. combatlearn-2.4.0/tests/test_property.py +426 -0
  35. combatlearn-2.4.0/tests/test_transductive.py +194 -0
  36. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_visualization.py +3 -5
  37. combatlearn-2.2.0/combatlearn/__init__.py +0 -5
  38. combatlearn-2.2.0/combatlearn/_utils.py +0 -21
  39. combatlearn-2.2.0/combatlearn/sklearn_api.py +0 -362
  40. combatlearn-2.2.0/tests/test_combat.py +0 -523
  41. {combatlearn-2.2.0 → combatlearn-2.4.0}/LICENSE +0 -0
  42. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn/py.typed +0 -0
  43. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  44. {combatlearn-2.2.0 → combatlearn-2.4.0}/combatlearn.egg-info/top_level.txt +0 -0
  45. {combatlearn-2.2.0 → combatlearn-2.4.0}/requirements-docs.txt +0 -0
  46. {combatlearn-2.2.0 → combatlearn-2.4.0}/setup.cfg +0 -0
  47. {combatlearn-2.2.0 → combatlearn-2.4.0}/tests/test_metrics.py +0 -0
@@ -1,19 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combatlearn
3
- Version: 2.2.0
3
+ Version: 2.4.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
15
19
  Requires-Dist: pandas>=1.3
16
20
  Requires-Dist: numpy>=1.21
21
+ Requires-Dist: scipy>=1.8
17
22
  Requires-Dist: scikit-learn>=1.2
18
23
  Requires-Dist: matplotlib>=3.4
19
24
  Requires-Dist: seaborn>=0.12
@@ -23,11 +28,13 @@ Requires-Dist: umap-learn>=0.5
23
28
  Provides-Extra: dev
24
29
  Requires-Dist: pytest>=7; extra == "dev"
25
30
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
31
+ Requires-Dist: hypothesis>=6.0; extra == "dev"
26
32
  Requires-Dist: ruff>=0.8; extra == "dev"
27
33
  Requires-Dist: pre-commit>=3.0; extra == "dev"
28
34
  Requires-Dist: mypy>=1.0; extra == "dev"
29
35
  Requires-Dist: pandas-stubs; extra == "dev"
30
36
  Requires-Dist: statsmodels>=0.14; extra == "dev"
37
+ Requires-Dist: inmoose>=0.7; extra == "dev"
31
38
  Provides-Extra: docs
32
39
  Requires-Dist: sphinx>=7.0.0; extra == "docs"
33
40
  Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
@@ -40,7 +47,7 @@ Dynamic: license-file
40
47
 
41
48
  [![Python versions](https://img.shields.io/badge/python-%3E%3D3.10-blue?logo=python)](https://www.python.org/)
42
49
  [![Test](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml/badge.svg)](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml)
43
- [![Documentation](https://readthedocs.org/projects/combatlearn/badge/?version=latest)](https://combatlearn.readthedocs.io)
50
+ [![Documentation](https://app.readthedocs.org/projects/combatlearn/badge/?version=latest)](https://combatlearn.readthedocs.io)
44
51
  [![PyPI Version](https://img.shields.io/pypi/v/combatlearn?cacheSeconds=300)](https://pypi.org/project/combatlearn/)
45
52
  [![License](https://img.shields.io/github/license/EttoreRocchi/combatlearn)](https://github.com/EttoreRocchi/combatlearn/blob/main/LICENSE)
46
53
 
@@ -50,14 +57,23 @@ Dynamic: license-file
50
57
 
51
58
  **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
59
 
53
- **Six methods**:
60
+ **Inductive `ComBat` methods** (fit on train, apply to held-out data, cross-validation-safe):
54
61
  - `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
55
62
  - `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
56
63
  - `method="chen"` - CovBat (Chen _et al._, 2022)
57
- - `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
58
64
  - `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
59
65
  - `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
60
66
 
67
+ **Inductive estimators with their own signature** (same fit/transform contract, separate classes):
68
+ - `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)
69
+ - `LongitudinalComBat` - repeated-measures / longitudinal designs with a per-subject random intercept (Beer _et al._, 2020). Inductive under a stricter precondition than `ComBat`: the batch **and** the subject must have been seen at fit; unseen subjects degrade to the population correction, or raise with `on_unseen_subject="error"`
70
+ - `GLMComBat` - non-Gaussian data via a parametric GLM plus a strictly per-observation quantile map:
71
+ - `method="seq"` - ComBat-seq for raw RNA-seq counts, negative-binomial with a library-size offset (Zhang _et al._, 2020). Integer in, integer out
72
+ - `method="met"` - ComBat-met for DNA-methylation beta-values, beta regression with a logit link (Wang, 2025). Output stays strictly inside `(0, 1)`
73
+ - Both are **cross-validation-safe**, which no published implementation of either method offers: upstream they are whole-cohort functions
74
+
75
+ **Whole-cohort (transductive)** - `combatlearn.transductive.TransductiveComBat` holds configurations that cannot be frozen at fit, namely those protecting a condition-of-interest / outcome term in the design, used as a one-shot `fit_transform`. Its principal occupants are `method="seq"` and `method="met"`, the condition-protected ComBat-seq / ComBat-met variants for differential expression and differential methylation. `method="longitudinal"` is deprecated in favor of `LongitudinalComBat`, as is `ComBat(method="longitudinal")`; both are removed in v3.0.0.
76
+
61
77
  ## Installation
62
78
 
63
79
  ```bash
@@ -121,6 +137,74 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
121
137
 
122
138
  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
139
 
140
+ ## Multiple batch variables (`NestedComBat`)
141
+
142
+ 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`.
143
+
144
+ ```python
145
+ import pandas as pd
146
+ from combatlearn import NestedComBat
147
+
148
+ batch = pd.DataFrame({"site": site, "scanner": scanner}) # one column per batch variable
149
+
150
+ nested = NestedComBat(
151
+ batch=batch,
152
+ continuous_covariates=age,
153
+ discrete_covariates=diag,
154
+ method="fortin", # per-step engine: "fortin"/"chen"/"gam"/"covbat_gam"
155
+ optimize_order=True, # choose the order (OPNested)
156
+ gmm=None, # or "batch" (+GMM) / "covariate" (-GMM)
157
+ )
158
+ X_corrected = nested.fit_transform(X)
159
+ print("Harmonization order:", nested.order_)
160
+ ```
161
+
162
+ ## Counts and proportions (`GLMComBat`)
163
+
164
+ RNA-seq counts and methylation beta-values are not Gaussian, and standardizing them destroys the structure downstream tools rely on. `GLMComBat` fits a negative-binomial GLM (`method="seq"`) or a beta regression (`method="met"`) and then maps each observation through the CDF of its own batch's fitted distribution into the corresponding quantile of a batch-free one.
165
+
166
+ That quantile map is **strictly per observation** - it uses only the sample's own measurements, batch label, covariates and (for `"seq"`) library size - so the model freezes at `fit` and `transform` applies to held-out data. The published ComBat-seq and ComBat-met implementations are whole-cohort functions with no such split.
167
+
168
+ ```python
169
+ from combatlearn import GLMComBat
170
+
171
+ harmonizer = GLMComBat(
172
+ batch=batch,
173
+ method="seq", # raw integer counts; "met" for 0-1 beta-values
174
+ discrete_covariates=tissue, # ordinary covariates, preserved
175
+ reference_batch="site_A", # recommended under cross-validation
176
+ )
177
+
178
+ counts_corrected = harmonizer.fit_transform(counts) # reproduces the reference exactly
179
+
180
+ harmonizer.fit(counts_train) # ... or freeze and reuse
181
+ counts_test_corrected = harmonizer.transform(counts_test)
182
+ ```
183
+
184
+ `fit_transform` on a whole cohort is verified bit-exact against InMoose `pycombat_seq`, and to within 1e-6 against committed fixtures from the ComBat-met R package. The **inductive** path has no external reference to compare with - it is validated by its defining property, that a held-out sample is corrected identically alone or in company.
185
+
186
+ If the term you want to protect *is* the supervised target of a differential analysis on the same cohort, use `TransductiveComBat(method="seq"/"met", condition=...)` instead; if it is known at intake, it is an ordinary covariate and belongs here.
187
+
188
+ ## Repeated measures (`LongitudinalComBat`)
189
+
190
+ When the same subjects are measured more than once, `LongitudinalComBat` adds a per-subject random intercept (REML) to the Fortin model, so within-subject correlation is modeled rather than treated as noise. It keeps the `fit`/`transform` contract, so a frozen correction can be applied to later visits of subjects already in the reference cohort.
191
+
192
+ ```python
193
+ from combatlearn import LongitudinalComBat
194
+
195
+ harmonizer = LongitudinalComBat(
196
+ batch=batch,
197
+ subject_id=subject, # required: one label per sample
198
+ time_covariate=visit, # optional: continuous time variable
199
+ continuous_covariates=age,
200
+ )
201
+
202
+ X_corrected = harmonizer.fit_transform(X) # whole cohort in one pass
203
+ X_new = harmonizer.fit(X_ref).transform(X_visits) # or freeze and apply
204
+ ```
205
+
206
+ Its precondition is one step stricter than `ComBat`'s: `ComBat` needs the *batch* to have been seen at fit, `LongitudinalComBat` needs the *batch and the subject*. A subject unseen at fit degrades to the population correction (zero random intercept), or raises with `on_unseen_subject="error"`. The transform is leakage-free in every split - each subject's intercept is estimated from that subject's training visits only - but for cross-validated prediction on *genuinely new* subjects there is no advantage over `method="fortin"`; the payoff is incremental harmonization of returning subjects.
207
+
124
208
  ## `ComBat` parameters
125
209
 
126
210
  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,11 +223,11 @@ The following section provides a detailed explanation of all parameters availabl
139
223
 
140
224
  | Parameter | Type | Default | Description |
141
225
  | --- | --- | --- | --- |
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"`). |
143
- | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
144
- | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
226
+ | `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.LongitudinalComBat`</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"`). |
227
+ | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, the non-parametric estimate of `sva::ComBat` (`int.eprior`) is used, whose cost grows with the square of the number of features. |
228
+ | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. For `"chen"` and `"covbat_gam"` it applies to the ComBat step only; the covariance step still harmonizes the PC-score variances. |
145
229
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
146
- | `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. |
230
+ | `covbat_cov_thresh` | float, int | `0.9` | For the `"chen"` and `"covbat_gam"` methods (also inside `NestedComBat`): 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. |
147
231
  | `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. |
148
232
  | `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
149
233
  | `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
@@ -205,3 +289,7 @@ Please consider citing the original papers:
205
289
  - [**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
290
 
207
291
  - [**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)
292
+
293
+ - [**ComBat-seq**](https://github.com/zhangyuqing/ComBat-seq) - Zhang Y, Parmigiani G, Johnson WE. _NAR Genom Bioinform_. 2020. doi: [10.1093/nargab/lqaa078](https://doi.org/10.1093/nargab/lqaa078)
294
+
295
+ - [**ComBat-met**](https://github.com/JmWangBio/ComBatMet) - Wang J. _NAR Genom Bioinform_. 2025. doi: [10.1093/nargab/lqaf062](https://doi.org/10.1093/nargab/lqaf062)
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Python versions](https://img.shields.io/badge/python-%3E%3D3.10-blue?logo=python)](https://www.python.org/)
4
4
  [![Test](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml/badge.svg)](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml)
5
- [![Documentation](https://readthedocs.org/projects/combatlearn/badge/?version=latest)](https://combatlearn.readthedocs.io)
5
+ [![Documentation](https://app.readthedocs.org/projects/combatlearn/badge/?version=latest)](https://combatlearn.readthedocs.io)
6
6
  [![PyPI Version](https://img.shields.io/pypi/v/combatlearn?cacheSeconds=300)](https://pypi.org/project/combatlearn/)
7
7
  [![License](https://img.shields.io/github/license/EttoreRocchi/combatlearn)](https://github.com/EttoreRocchi/combatlearn/blob/main/LICENSE)
8
8
 
@@ -12,14 +12,23 @@
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
- **Six 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)
20
19
  - `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
21
20
  - `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
22
21
 
22
+ **Inductive estimators with their own signature** (same fit/transform contract, separate classes):
23
+ - `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)
24
+ - `LongitudinalComBat` - repeated-measures / longitudinal designs with a per-subject random intercept (Beer _et al._, 2020). Inductive under a stricter precondition than `ComBat`: the batch **and** the subject must have been seen at fit; unseen subjects degrade to the population correction, or raise with `on_unseen_subject="error"`
25
+ - `GLMComBat` - non-Gaussian data via a parametric GLM plus a strictly per-observation quantile map:
26
+ - `method="seq"` - ComBat-seq for raw RNA-seq counts, negative-binomial with a library-size offset (Zhang _et al._, 2020). Integer in, integer out
27
+ - `method="met"` - ComBat-met for DNA-methylation beta-values, beta regression with a logit link (Wang, 2025). Output stays strictly inside `(0, 1)`
28
+ - Both are **cross-validation-safe**, which no published implementation of either method offers: upstream they are whole-cohort functions
29
+
30
+ **Whole-cohort (transductive)** - `combatlearn.transductive.TransductiveComBat` holds configurations that cannot be frozen at fit, namely those protecting a condition-of-interest / outcome term in the design, used as a one-shot `fit_transform`. Its principal occupants are `method="seq"` and `method="met"`, the condition-protected ComBat-seq / ComBat-met variants for differential expression and differential methylation. `method="longitudinal"` is deprecated in favor of `LongitudinalComBat`, as is `ComBat(method="longitudinal")`; both are removed in v3.0.0.
31
+
23
32
  ## Installation
24
33
 
25
34
  ```bash
@@ -83,6 +92,74 @@ print(f"Best CV AUROC: {grid.best_score_:.3f}")
83
92
 
84
93
  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)
85
94
 
95
+ ## Multiple batch variables (`NestedComBat`)
96
+
97
+ 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`.
98
+
99
+ ```python
100
+ import pandas as pd
101
+ from combatlearn import NestedComBat
102
+
103
+ batch = pd.DataFrame({"site": site, "scanner": scanner}) # one column per batch variable
104
+
105
+ nested = NestedComBat(
106
+ batch=batch,
107
+ continuous_covariates=age,
108
+ discrete_covariates=diag,
109
+ method="fortin", # per-step engine: "fortin"/"chen"/"gam"/"covbat_gam"
110
+ optimize_order=True, # choose the order (OPNested)
111
+ gmm=None, # or "batch" (+GMM) / "covariate" (-GMM)
112
+ )
113
+ X_corrected = nested.fit_transform(X)
114
+ print("Harmonization order:", nested.order_)
115
+ ```
116
+
117
+ ## Counts and proportions (`GLMComBat`)
118
+
119
+ RNA-seq counts and methylation beta-values are not Gaussian, and standardizing them destroys the structure downstream tools rely on. `GLMComBat` fits a negative-binomial GLM (`method="seq"`) or a beta regression (`method="met"`) and then maps each observation through the CDF of its own batch's fitted distribution into the corresponding quantile of a batch-free one.
120
+
121
+ That quantile map is **strictly per observation** - it uses only the sample's own measurements, batch label, covariates and (for `"seq"`) library size - so the model freezes at `fit` and `transform` applies to held-out data. The published ComBat-seq and ComBat-met implementations are whole-cohort functions with no such split.
122
+
123
+ ```python
124
+ from combatlearn import GLMComBat
125
+
126
+ harmonizer = GLMComBat(
127
+ batch=batch,
128
+ method="seq", # raw integer counts; "met" for 0-1 beta-values
129
+ discrete_covariates=tissue, # ordinary covariates, preserved
130
+ reference_batch="site_A", # recommended under cross-validation
131
+ )
132
+
133
+ counts_corrected = harmonizer.fit_transform(counts) # reproduces the reference exactly
134
+
135
+ harmonizer.fit(counts_train) # ... or freeze and reuse
136
+ counts_test_corrected = harmonizer.transform(counts_test)
137
+ ```
138
+
139
+ `fit_transform` on a whole cohort is verified bit-exact against InMoose `pycombat_seq`, and to within 1e-6 against committed fixtures from the ComBat-met R package. The **inductive** path has no external reference to compare with - it is validated by its defining property, that a held-out sample is corrected identically alone or in company.
140
+
141
+ If the term you want to protect *is* the supervised target of a differential analysis on the same cohort, use `TransductiveComBat(method="seq"/"met", condition=...)` instead; if it is known at intake, it is an ordinary covariate and belongs here.
142
+
143
+ ## Repeated measures (`LongitudinalComBat`)
144
+
145
+ When the same subjects are measured more than once, `LongitudinalComBat` adds a per-subject random intercept (REML) to the Fortin model, so within-subject correlation is modeled rather than treated as noise. It keeps the `fit`/`transform` contract, so a frozen correction can be applied to later visits of subjects already in the reference cohort.
146
+
147
+ ```python
148
+ from combatlearn import LongitudinalComBat
149
+
150
+ harmonizer = LongitudinalComBat(
151
+ batch=batch,
152
+ subject_id=subject, # required: one label per sample
153
+ time_covariate=visit, # optional: continuous time variable
154
+ continuous_covariates=age,
155
+ )
156
+
157
+ X_corrected = harmonizer.fit_transform(X) # whole cohort in one pass
158
+ X_new = harmonizer.fit(X_ref).transform(X_visits) # or freeze and apply
159
+ ```
160
+
161
+ Its precondition is one step stricter than `ComBat`'s: `ComBat` needs the *batch* to have been seen at fit, `LongitudinalComBat` needs the *batch and the subject*. A subject unseen at fit degrades to the population correction (zero random intercept), or raises with `on_unseen_subject="error"`. The transform is leakage-free in every split - each subject's intercept is estimated from that subject's training visits only - but for cross-validated prediction on *genuinely new* subjects there is no advantage over `method="fortin"`; the payoff is incremental harmonization of returning subjects.
162
+
86
163
  ## `ComBat` parameters
87
164
 
88
165
  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/).
@@ -101,11 +178,11 @@ The following section provides a detailed explanation of all parameters availabl
101
178
 
102
179
  | Parameter | Type | Default | Description |
103
180
  | --- | --- | --- | --- |
104
- | `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"`). |
105
- | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
106
- | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
181
+ | `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.LongitudinalComBat`</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"`). |
182
+ | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, the non-parametric estimate of `sva::ComBat` (`int.eprior`) is used, whose cost grows with the square of the number of features. |
183
+ | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. For `"chen"` and `"covbat_gam"` it applies to the ComBat step only; the covariance step still harmonizes the PC-score variances. |
107
184
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
108
- | `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. |
185
+ | `covbat_cov_thresh` | float, int | `0.9` | For the `"chen"` and `"covbat_gam"` methods (also inside `NestedComBat`): 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. |
109
186
  | `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. |
110
187
  | `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
111
188
  | `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
@@ -166,4 +243,8 @@ Please consider citing the original papers:
166
243
 
167
244
  - [**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)
168
245
 
169
- - [**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)
246
+ - [**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)
247
+
248
+ - [**ComBat-seq**](https://github.com/zhangyuqing/ComBat-seq) - Zhang Y, Parmigiani G, Johnson WE. _NAR Genom Bioinform_. 2020. doi: [10.1093/nargab/lqaa078](https://doi.org/10.1093/nargab/lqaa078)
249
+
250
+ - [**ComBat-met**](https://github.com/JmWangBio/ComBatMet) - Wang J. _NAR Genom Bioinform_. 2025. doi: [10.1093/nargab/lqaf062](https://doi.org/10.1093/nargab/lqaf062)
@@ -0,0 +1,6 @@
1
+ from .glm import GLMComBat
2
+ from .sklearn_api import ComBat, LongitudinalComBat, NestedComBat
3
+
4
+ __all__ = ["ComBat", "GLMComBat", "LongitudinalComBat", "NestedComBat"]
5
+ __version__ = "2.4.0"
6
+ __author__ = "Ettore Rocchi"
@@ -1,23 +1,8 @@
1
- """Random-intercept linear mixed model fitting for Longitudinal ComBat.
1
+ """Random-intercept linear mixed model, fit by REML, for Longitudinal ComBat.
2
2
 
3
- Fits, for every feature (column of ``Y``) simultaneously, the model
4
-
5
- y = X @ beta + Z @ u + eps, u ~ N(0, tau^2 I), eps ~ N(0, sigma^2 I)
6
-
7
- where ``Z`` is the subject (random-intercept) indicator matrix. Estimation is
8
- by REML, profiling out ``beta`` and ``sigma^2`` so that only the scalar
9
- variance ratio ``lambda = tau^2 / sigma^2`` is optimized per feature.
10
-
11
- The single random-intercept grouping makes the marginal covariance
12
- ``V = sigma^2 (I + lambda Z Z^T)`` block diagonal (one block per subject), so
13
- ``V^{-1}`` is available in closed form via Sherman-Morrison and every operation
14
- is O(n) per feature. The variance ratio is found by a vectorized grid search
15
- over ``lambda`` followed by a parabolic refinement, after which the fixed
16
- effects, residual variance, and random-intercept BLUPs are computed in a single
17
- per-feature pass.
18
-
19
- This keeps Longitudinal ComBat dependency-free (numpy only); correctness is
20
- cross-checked against ``statsmodels.MixedLM`` in the test suite.
3
+ Fits ``y = X beta + Z u + eps`` per feature, profiling out ``beta`` and ``sigma^2``
4
+ so only ``lambda = tau^2 / sigma^2`` is searched; the block-diagonal covariance is
5
+ inverted in O(n) via Sherman-Morrison.
21
6
  """
22
7
 
23
8
  from __future__ import annotations
@@ -84,6 +69,8 @@ def fit_random_intercept(
84
69
  ) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray]:
85
70
  """Fit a random-intercept LME per feature by REML.
86
71
 
72
+ ``lambda`` is found by a log-spaced grid search refined by a parabolic step.
73
+
87
74
  Parameters
88
75
  ----------
89
76
  X : ndarray of shape (n, p)
@@ -120,7 +107,6 @@ def fit_random_intercept(
120
107
  n_features = Y.shape[1]
121
108
  ridge = eps * float(np.trace(X.T @ X)) / max(p, 1)
122
109
 
123
- # Vectorized grid search for the variance ratio (per feature).
124
110
  log_grid = np.linspace(-4.0, 4.0, n_grid)
125
111
  lam_grid = np.concatenate([[0.0], 10.0**log_grid])
126
112
  fvals = np.empty((lam_grid.size, n_features), dtype=np.float64)
@@ -129,7 +115,6 @@ def fit_random_intercept(
129
115
  best = np.argmin(fvals, axis=0)
130
116
  lam_opt = lam_grid[best].astype(np.float64)
131
117
 
132
- # Vectorized parabolic refinement (log10 space) for interior positive optima.
133
118
  interior = (best >= 2) & (best <= n_grid - 1)
134
119
  if np.any(interior):
135
120
  bi = best[interior]
@@ -142,7 +127,6 @@ def fit_random_intercept(
142
127
  step = log_grid[1] - log_grid[0]
143
128
  lam_opt[interior] = 10.0 ** (log_grid[bi - 1] + delta * step)
144
129
 
145
- # Final per-feature fixed effects, residual variance, and random-intercept BLUPs.
146
130
  beta = np.empty((p, n_features), dtype=np.float64)
147
131
  sigma2 = np.empty(n_features, dtype=np.float64)
148
132
  blup = np.zeros((n_groups, n_features), dtype=np.float64)