combatlearn 2.3.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 (48) hide show
  1. {combatlearn-2.3.0 → combatlearn-2.4.0}/PKG-INFO +66 -8
  2. {combatlearn-2.3.0 → combatlearn-2.4.0}/README.md +64 -8
  3. combatlearn-2.4.0/combatlearn/__init__.py +6 -0
  4. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/_mixed.py +6 -22
  5. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/_nested.py +15 -50
  6. combatlearn-2.4.0/combatlearn/_utils.py +122 -0
  7. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/core.py +218 -216
  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.3.0 → combatlearn-2.4.0}/combatlearn/inspection.py +11 -21
  16. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/metrics.py +21 -228
  17. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/sklearn_api.py +291 -82
  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.3.0 → combatlearn-2.4.0}/combatlearn/visualization.py +25 -18
  21. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn.egg-info/PKG-INFO +66 -8
  22. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn.egg-info/SOURCES.txt +8 -0
  23. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn.egg-info/requires.txt +2 -0
  24. {combatlearn-2.3.0 → combatlearn-2.4.0}/requirements-dev.txt +1 -0
  25. {combatlearn-2.3.0 → combatlearn-2.4.0}/requirements.txt +1 -0
  26. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_combat.py +416 -7
  27. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_eb_degenerate.py +10 -13
  28. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_gam.py +80 -31
  29. combatlearn-2.4.0/tests/test_glm.py +539 -0
  30. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_inspection.py +0 -1
  31. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_longitudinal.py +143 -2
  32. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_nested.py +62 -5
  33. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_property.py +165 -36
  34. combatlearn-2.4.0/tests/test_transductive.py +194 -0
  35. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_visualization.py +3 -5
  36. combatlearn-2.3.0/combatlearn/__init__.py +0 -5
  37. combatlearn-2.3.0/combatlearn/_utils.py +0 -66
  38. combatlearn-2.3.0/combatlearn/transductive/__init__.py +0 -15
  39. combatlearn-2.3.0/combatlearn/transductive/_transductive.py +0 -189
  40. combatlearn-2.3.0/tests/test_transductive.py +0 -74
  41. {combatlearn-2.3.0 → combatlearn-2.4.0}/LICENSE +0 -0
  42. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn/py.typed +0 -0
  43. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  44. {combatlearn-2.3.0 → combatlearn-2.4.0}/combatlearn.egg-info/top_level.txt +0 -0
  45. {combatlearn-2.3.0 → combatlearn-2.4.0}/pyproject.toml +0 -0
  46. {combatlearn-2.3.0 → combatlearn-2.4.0}/requirements-docs.txt +0 -0
  47. {combatlearn-2.3.0 → combatlearn-2.4.0}/setup.cfg +0 -0
  48. {combatlearn-2.3.0 → combatlearn-2.4.0}/tests/test_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combatlearn
3
- Version: 2.3.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
6
  License-Expression: MIT
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: pandas>=1.3
20
20
  Requires-Dist: numpy>=1.21
21
+ Requires-Dist: scipy>=1.8
21
22
  Requires-Dist: scikit-learn>=1.2
22
23
  Requires-Dist: matplotlib>=3.4
23
24
  Requires-Dist: seaborn>=0.12
@@ -33,6 +34,7 @@ Requires-Dist: pre-commit>=3.0; extra == "dev"
33
34
  Requires-Dist: mypy>=1.0; extra == "dev"
34
35
  Requires-Dist: pandas-stubs; extra == "dev"
35
36
  Requires-Dist: statsmodels>=0.14; extra == "dev"
37
+ Requires-Dist: inmoose>=0.7; extra == "dev"
36
38
  Provides-Extra: docs
37
39
  Requires-Dist: sphinx>=7.0.0; extra == "docs"
38
40
  Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
@@ -45,7 +47,7 @@ Dynamic: license-file
45
47
 
46
48
  [![Python versions](https://img.shields.io/badge/python-%3E%3D3.10-blue?logo=python)](https://www.python.org/)
47
49
  [![Test](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml/badge.svg)](https://github.com/EttoreRocchi/combatlearn/actions/workflows/test.yaml)
48
- [![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)
49
51
  [![PyPI Version](https://img.shields.io/pypi/v/combatlearn?cacheSeconds=300)](https://pypi.org/project/combatlearn/)
50
52
  [![License](https://img.shields.io/github/license/EttoreRocchi/combatlearn)](https://github.com/EttoreRocchi/combatlearn/blob/main/LICENSE)
51
53
 
@@ -62,9 +64,15 @@ Dynamic: license-file
62
64
  - `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
63
65
  - `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
64
66
 
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).
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
66
74
 
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.)
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.
68
76
 
69
77
  ## Installation
70
78
 
@@ -151,6 +159,52 @@ X_corrected = nested.fit_transform(X)
151
159
  print("Harmonization order:", nested.order_)
152
160
  ```
153
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
+
154
208
  ## `ComBat` parameters
155
209
 
156
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/).
@@ -169,11 +223,11 @@ The following section provides a detailed explanation of all parameters availabl
169
223
 
170
224
  | Parameter | Type | Default | Description |
171
225
  | --- | --- | --- | --- |
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"`). |
173
- | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
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. |
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. |
175
229
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
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. |
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. |
177
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. |
178
232
  | `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
179
233
  | `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
@@ -235,3 +289,7 @@ Please consider citing the original papers:
235
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)
236
290
 
237
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
 
@@ -19,9 +19,15 @@
19
19
  - `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
20
20
  - `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
21
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).
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
23
29
 
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.)
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.
25
31
 
26
32
  ## Installation
27
33
 
@@ -108,6 +114,52 @@ X_corrected = nested.fit_transform(X)
108
114
  print("Harmonization order:", nested.order_)
109
115
  ```
110
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
+
111
163
  ## `ComBat` parameters
112
164
 
113
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/).
@@ -126,11 +178,11 @@ The following section provides a detailed explanation of all parameters availabl
126
178
 
127
179
  | Parameter | Type | Default | Description |
128
180
  | --- | --- | --- | --- |
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"`). |
130
- | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
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. |
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. |
132
184
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
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. |
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. |
134
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. |
135
187
  | `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
136
188
  | `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
@@ -191,4 +243,8 @@ Please consider citing the original papers:
191
243
 
192
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)
193
245
 
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)
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)
@@ -1,21 +1,7 @@
1
1
  """Orchestration helpers for NestedComBat (*Horng et al.* 2022).
2
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.
3
+ The sequential single-batch ComBat fits, the Anderson-Darling order-search
4
+ objective, and the GMM grouping; all are learned at fit and frozen for transform.
19
5
  """
20
6
 
21
7
  from __future__ import annotations
@@ -61,12 +47,9 @@ def anderson_darling_batch_count(
61
47
  ) -> int:
62
48
  """Number of features with a significant residual batch effect.
63
49
 
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.
50
+ Counts (feature, batch variable) pairs whose Anderson-Darling k-sample test
51
+ (:func:`scipy.stats.anderson_ksamp`) is significant at ``alpha``; degenerate
52
+ features do not count. This is the objective the OPNested order search minimizes.
70
53
 
71
54
  Parameters
72
55
  ----------
@@ -86,8 +69,7 @@ def anderson_darling_batch_count(
86
69
  n_features = col_values.shape[1]
87
70
  total = 0
88
71
  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.
72
+ # Warns only when p is capped to [0.001, 0.25]; harmless at alpha=0.05.
91
73
  warnings.simplefilter("ignore")
92
74
  for batch in batch_vars.values():
93
75
  labels = np.asarray(batch)
@@ -101,8 +83,6 @@ def anderson_darling_batch_count(
101
83
  try:
102
84
  result = anderson_ksamp(samples)
103
85
  except ValueError:
104
- # Degenerate feature (e.g. constant within the pooled sample):
105
- # no detectable batch effect, so it does not count.
106
86
  continue
107
87
  if result.significance_level < alpha:
108
88
  total += 1
@@ -119,11 +99,8 @@ def fit_nested_sequence(
119
99
  ) -> tuple[list[ModelStep], pd.DataFrame]:
120
100
  """Fit one ``ComBatModel`` per batch variable in ``order``, chaining transforms.
121
101
 
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.
102
+ Covariates are protected at every step. Returns the ``(batch_variable, model)``
103
+ steps and the fully harmonized training data.
127
104
  """
128
105
  models: list[ModelStep] = []
129
106
  X_current = X
@@ -187,8 +164,7 @@ def _search_greedy(
187
164
  make_model: Callable[[str], ComBatModel],
188
165
  alpha: float,
189
166
  ) -> 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."""
167
+ """Greedy forward selection: repeatedly apply the step that most lowers the AD count."""
192
168
  remaining = list(names)
193
169
  order: list[str] = []
194
170
  models: list[ModelStep] = []
@@ -232,13 +208,9 @@ def search_order(
232
208
  ) -> tuple[list[str], list[ModelStep], bool]:
233
209
  """Select a harmonization order that minimizes the residual batch effect.
234
210
 
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.
211
+ Exhaustive over all ``k!`` orderings when ``k <= max_exhaustive_vars`` (warning
212
+ from ``k >= 4``), else greedy with a warning. Returns the order, its fitted
213
+ sequence, and whether the greedy fallback was used.
242
214
  """
243
215
  k = len(names)
244
216
  if k <= max_exhaustive_vars:
@@ -270,16 +242,9 @@ def search_order(
270
242
  class GMMGrouping:
271
243
  """A per-feature Gaussian-mixture grouping (*Horng et al.* 2022, GMM ComBat).
272
244
 
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.
245
+ A 2-component mixture is fit to each feature; among those whose smaller cluster
246
+ exceeds ``min_cluster_frac`` of the samples, the lowest-AIC one gives the grouping,
247
+ used as a batch variable (``+GMM``) or covariate (``-GMM``).
283
248
  """
284
249
 
285
250
  def __init__(self, feature: object, gmm: GaussianMixture) -> None:
@@ -0,0 +1,122 @@
1
+ """Internal utilities shared across combatlearn modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Iterable
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ if TYPE_CHECKING:
13
+ from .core import ArrayLike
14
+
15
+
16
+ def _fit_levels(values: pd.Series) -> pd.Index:
17
+ """Levels a discrete covariate takes in the fitted rows (unused categories dropped)."""
18
+ levels: pd.Index = pd.Index(pd.Categorical(values).remove_unused_categories().categories)
19
+ return levels
20
+
21
+
22
+ def _code_levels(values: pd.Series, levels: pd.Index, label: str) -> pd.Categorical:
23
+ """Code values against the levels frozen at fit; a level not seen at fit raises.
24
+
25
+ Dummy coding would otherwise turn it into an all-zero row, silently the reference level.
26
+ """
27
+ codes = levels.get_indexer(pd.Index(values))
28
+ unseen = pd.unique(values[(codes == -1) & values.notna().to_numpy()])
29
+ if len(unseen):
30
+ raise ValueError(
31
+ f"{label} has level(s) {sorted(map(str, unseen))} that were "
32
+ f"not seen at fit. A level with no training samples has no estimated covariate "
33
+ f"effect, so it cannot be corrected; coding it as the reference level would "
34
+ f"silently apply the wrong one. Seen at fit: {sorted(map(str, levels))}."
35
+ )
36
+ return pd.Categorical.from_codes(codes.tolist(), categories=levels)
37
+
38
+
39
+ def _check_positional_alignment(
40
+ X: ArrayLike, auxiliaries: Iterable[tuple[str, ArrayLike | None]]
41
+ ) -> None:
42
+ """Require length-matched auxiliaries when ``X`` carries no index labels.
43
+
44
+ A plain-array ``X`` can only align positionally, which breaks inside CV when the
45
+ splitter subsets ``X`` but construction-time ``batch``/covariates stay full-length.
46
+ """
47
+ if isinstance(X, pd.DataFrame):
48
+ return
49
+ n = len(X)
50
+ for name, obj in auxiliaries:
51
+ if obj is None:
52
+ continue
53
+ if len(obj) != n:
54
+ raise ValueError(
55
+ f"`{name}` has length {len(obj)} but X has {n} rows. When X is not a "
56
+ f"pandas DataFrame it has no index labels, so combatlearn can only align "
57
+ f"`{name}` to X positionally, which requires equal lengths. This typically "
58
+ f"happens inside cross-validation, where the splitter subsets X but the "
59
+ f"batch/covariates passed at construction stay full-length. Pass X and "
60
+ f"`{name}` as pandas objects sharing the same index so folds stay "
61
+ f"label-aligned, or pass a same-length `{name}`."
62
+ )
63
+
64
+
65
+ def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
66
+ """Subset array-like object by index."""
67
+ if obj is None:
68
+ return None
69
+ if isinstance(obj, pd.Series | pd.DataFrame):
70
+ return obj.loc[idx]
71
+ else:
72
+ if isinstance(obj, np.ndarray) and obj.ndim == 1:
73
+ return pd.Series(obj, index=idx)
74
+ else:
75
+ return pd.DataFrame(obj, index=idx)
76
+
77
+
78
+ def _warn_if_covariate_determines_target(
79
+ y: ArrayLike | None,
80
+ discrete_covariates: pd.DataFrame | pd.Series | None,
81
+ *,
82
+ estimator: str,
83
+ stacklevel: int = 4,
84
+ ) -> None:
85
+ """Warn when a discrete covariate perfectly determines ``y`` (likely the target).
86
+
87
+ Protecting the target inflates cross-validated scores. ``discrete_covariates``
88
+ must already be subset to the fitted rows; covariates averaging fewer than two
89
+ samples per level are skipped to avoid false alarms.
90
+ """
91
+ if y is None or discrete_covariates is None:
92
+ return
93
+
94
+ target = pd.Series(np.asarray(y).ravel())
95
+ if len(target) != len(discrete_covariates) or target.nunique(dropna=False) < 2:
96
+ return
97
+
98
+ frame = (
99
+ discrete_covariates.to_frame()
100
+ if isinstance(discrete_covariates, pd.Series)
101
+ else discrete_covariates
102
+ )
103
+ n_samples = len(frame)
104
+ for column in frame.columns:
105
+ levels = pd.Series(np.asarray(frame[column]))
106
+ n_levels = levels.nunique(dropna=False)
107
+ if n_levels < 2 or n_levels * 2 > n_samples:
108
+ continue
109
+ if target.groupby(list(levels), dropna=False).nunique(dropna=False).max() > 1:
110
+ continue
111
+ warnings.warn(
112
+ f"{estimator}: discrete covariate {column!r} determines `y` on the training "
113
+ f"data, so this correction preserves the very signal you are about to predict, "
114
+ f"and `transform` applies it using each held-out sample's own label - which "
115
+ f"would not be available at prediction time. Any cross-validated score will be "
116
+ f"optimistic. If you are harmonizing a whole cohort for differential expression "
117
+ f"or methylation, use combatlearn.transductive.TransductiveComBat(condition=...), "
118
+ f"which protects the term and refuses `transform`. If {column!r} is genuinely "
119
+ f"known at intake and is not your target, this warning does not apply.",
120
+ UserWarning,
121
+ stacklevel=stacklevel,
122
+ )