combatlearn 2.0.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {combatlearn-2.0.0 → combatlearn-2.2.0}/PKG-INFO +21 -6
- {combatlearn-2.0.0 → combatlearn-2.2.0}/README.md +20 -6
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/__init__.py +1 -1
- combatlearn-2.2.0/combatlearn/_mixed.py +165 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/core.py +527 -22
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/sklearn_api.py +75 -8
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/visualization.py +105 -49
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/PKG-INFO +21 -6
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/SOURCES.txt +4 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/requires.txt +1 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements-dev.txt +1 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_combat.py +46 -0
- combatlearn-2.2.0/tests/test_eb_degenerate.py +111 -0
- combatlearn-2.2.0/tests/test_gam.py +298 -0
- combatlearn-2.2.0/tests/test_longitudinal.py +216 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_visualization.py +35 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/LICENSE +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/_utils.py +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/inspection.py +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/metrics.py +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/py.typed +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/top_level.txt +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/pyproject.toml +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements-docs.txt +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements.txt +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/setup.cfg +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_inspection.py +0 -0
- {combatlearn-2.0.0 → combatlearn-2.2.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
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Batch-effect harmonization for machine learning frameworks.
|
|
5
5
|
Author-email: Ettore Rocchi <ettoreroc@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -27,6 +27,7 @@ Requires-Dist: ruff>=0.8; extra == "dev"
|
|
|
27
27
|
Requires-Dist: pre-commit>=3.0; extra == "dev"
|
|
28
28
|
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
29
29
|
Requires-Dist: pandas-stubs; extra == "dev"
|
|
30
|
+
Requires-Dist: statsmodels>=0.14; extra == "dev"
|
|
30
31
|
Provides-Extra: docs
|
|
31
32
|
Requires-Dist: sphinx>=7.0.0; extra == "docs"
|
|
32
33
|
Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
|
|
@@ -49,10 +50,13 @@ Dynamic: license-file
|
|
|
49
50
|
|
|
50
51
|
**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**.
|
|
51
52
|
|
|
52
|
-
**
|
|
53
|
+
**Six methods**:
|
|
53
54
|
- `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
|
|
54
55
|
- `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
|
|
55
56
|
- `method="chen"` - CovBat (Chen _et al._, 2022)
|
|
57
|
+
- `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
|
|
58
|
+
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
59
|
+
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
56
60
|
|
|
57
61
|
## Installation
|
|
58
62
|
|
|
@@ -75,6 +79,7 @@ import pandas as pd
|
|
|
75
79
|
from sklearn.pipeline import Pipeline
|
|
76
80
|
from sklearn.preprocessing import StandardScaler
|
|
77
81
|
from sklearn.linear_model import LogisticRegression
|
|
82
|
+
from sklearn.model_selection import GridSearchCV
|
|
78
83
|
from combatlearn import ComBat
|
|
79
84
|
|
|
80
85
|
df = pd.read_csv("data.csv", index_col=0)
|
|
@@ -125,18 +130,24 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
125
130
|
| Parameter | Type | Default | Description |
|
|
126
131
|
| --- | --- | --- | --- |
|
|
127
132
|
| `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
|
|
128
|
-
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"` and `"
|
|
129
|
-
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"
|
|
133
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. |
|
|
134
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. **Required** for the GAM methods. |
|
|
135
|
+
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
136
|
+
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
130
137
|
|
|
131
138
|
### Algorithm Options
|
|
132
139
|
|
|
133
140
|
| Parameter | Type | Default | Description |
|
|
134
141
|
| --- | --- | --- | --- |
|
|
135
|
-
| `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></ul> |
|
|
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"`). |
|
|
136
143
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
137
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. |
|
|
138
145
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
139
|
-
| `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold
|
|
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. |
|
|
147
|
+
| `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
|
+
| `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
|
|
149
|
+
| `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
|
|
150
|
+
| `smooth_term_bounds` | tuple or dict | `None` | For the GAM methods: boundary knots, as a single `(lo, hi)` for all terms or a `{term: (lo, hi)}` dict. Default uses each term's training min/max; widen to cover held-out data. |
|
|
140
151
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
141
152
|
|
|
142
153
|
|
|
@@ -190,3 +201,7 @@ Please consider citing the original papers:
|
|
|
190
201
|
- [**neuroCombat**](https://github.com/Jfortin1/neuroCombat) - Fortin JP et al. _Neuroimage_. 2018. doi: [10.1016/j.neuroimage.2017.11.024](https://doi.org/10.1016/j.neuroimage.2017.11.024)
|
|
191
202
|
|
|
192
203
|
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
204
|
+
|
|
205
|
+
- [**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
|
+
|
|
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)
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
**combatlearn** makes the popular _ComBat_ (and _CovBat_) batch-effect correction algorithm available for use into machine learning frameworks. It lets you harmonise high-dimensional data inside a scikit-learn `Pipeline`, so that cross-validation and grid-search automatically take batch structure into account, **without data leakage**.
|
|
14
14
|
|
|
15
|
-
**
|
|
15
|
+
**Six methods**:
|
|
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
|
+
- `method="gam"` - ComBat-GAM, nonlinear (spline) covariate effects (Pomponio _et al._, 2020)
|
|
21
|
+
- `method="covbat_gam"` - CovBat with the same nonlinear covariate modeling
|
|
19
22
|
|
|
20
23
|
## Installation
|
|
21
24
|
|
|
@@ -38,6 +41,7 @@ import pandas as pd
|
|
|
38
41
|
from sklearn.pipeline import Pipeline
|
|
39
42
|
from sklearn.preprocessing import StandardScaler
|
|
40
43
|
from sklearn.linear_model import LogisticRegression
|
|
44
|
+
from sklearn.model_selection import GridSearchCV
|
|
41
45
|
from combatlearn import ComBat
|
|
42
46
|
|
|
43
47
|
df = pd.read_csv("data.csv", index_col=0)
|
|
@@ -88,18 +92,24 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
88
92
|
| Parameter | Type | Default | Description |
|
|
89
93
|
| --- | --- | --- | --- |
|
|
90
94
|
| `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
|
|
91
|
-
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"` and `"
|
|
92
|
-
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"
|
|
95
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. |
|
|
96
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, `"longitudinal"`, `"gam"`, and `"covbat_gam"` methods. **Required** for the GAM methods. |
|
|
97
|
+
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
98
|
+
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
93
99
|
|
|
94
100
|
### Algorithm Options
|
|
95
101
|
|
|
96
102
|
| Parameter | Type | Default | Description |
|
|
97
103
|
| --- | --- | --- | --- |
|
|
98
|
-
| `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></ul> |
|
|
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"`). |
|
|
99
105
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
100
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. |
|
|
101
107
|
| `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
|
|
102
|
-
| `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold
|
|
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. |
|
|
109
|
+
| `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
|
+
| `spline_df` | int | `10` | For the GAM methods: B-spline degrees of freedom (basis functions) per smooth term. |
|
|
111
|
+
| `spline_degree` | int | `3` | For the GAM methods: B-spline degree (3 = cubic). |
|
|
112
|
+
| `smooth_term_bounds` | tuple or dict | `None` | For the GAM methods: boundary knots, as a single `(lo, hi)` for all terms or a `{term: (lo, hi)}` dict. Default uses each term's training min/max; widen to cover held-out data. |
|
|
103
113
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
104
114
|
|
|
105
115
|
|
|
@@ -152,4 +162,8 @@ Please consider citing the original papers:
|
|
|
152
162
|
|
|
153
163
|
- [**neuroCombat**](https://github.com/Jfortin1/neuroCombat) - Fortin JP et al. _Neuroimage_. 2018. doi: [10.1016/j.neuroimage.2017.11.024](https://doi.org/10.1016/j.neuroimage.2017.11.024)
|
|
154
164
|
|
|
155
|
-
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
165
|
+
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
166
|
+
|
|
167
|
+
- [**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
|
+
|
|
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)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Random-intercept linear mixed model fitting for Longitudinal ComBat.
|
|
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.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import numpy.typing as npt
|
|
27
|
+
|
|
28
|
+
FloatArray = npt.NDArray[np.float64]
|
|
29
|
+
IntArray = npt.NDArray[np.intp]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _group_sums(M: FloatArray, group_idx: IntArray, n_groups: int) -> FloatArray:
|
|
33
|
+
"""Sum the rows of ``M`` (n, c) within each group, returning (n_groups, c)."""
|
|
34
|
+
out = np.zeros((n_groups, M.shape[1]), dtype=np.float64)
|
|
35
|
+
np.add.at(out, group_idx, M)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _w_apply(M: FloatArray, factor: FloatArray, group_idx: IntArray, n_groups: int) -> FloatArray:
|
|
40
|
+
"""Apply ``W = (I + lambda Z Z^T)^{-1}`` to the columns of ``M``.
|
|
41
|
+
|
|
42
|
+
``factor[k] = lambda / (1 + lambda * n_k)`` is the Sherman-Morrison
|
|
43
|
+
per-group correction (``n_k`` = number of observations in group ``k``).
|
|
44
|
+
"""
|
|
45
|
+
gs = _group_sums(M, group_idx, n_groups)
|
|
46
|
+
return M - factor[group_idx][:, None] * gs[group_idx]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reml_objective(
|
|
50
|
+
lam: float,
|
|
51
|
+
X: FloatArray,
|
|
52
|
+
Y: FloatArray,
|
|
53
|
+
group_idx: IntArray,
|
|
54
|
+
n_groups: int,
|
|
55
|
+
n_k: FloatArray,
|
|
56
|
+
n: int,
|
|
57
|
+
p: int,
|
|
58
|
+
ridge: float,
|
|
59
|
+
) -> FloatArray:
|
|
60
|
+
"""Profiled REML objective (to minimize) evaluated for all features at ``lam``."""
|
|
61
|
+
factor = lam / (1.0 + lam * n_k)
|
|
62
|
+
WX = _w_apply(X, factor, group_idx, n_groups)
|
|
63
|
+
A = X.T @ WX
|
|
64
|
+
A.flat[:: p + 1] += ridge
|
|
65
|
+
beta = np.linalg.solve(A, X.T @ _w_apply(Y, factor, group_idx, n_groups))
|
|
66
|
+
R = Y - X @ beta
|
|
67
|
+
WR = _w_apply(R, factor, group_idx, n_groups)
|
|
68
|
+
rss = np.maximum(np.einsum("ng,ng->g", R, WR), 1e-12)
|
|
69
|
+
logdet_v = float(np.sum(np.log1p(lam * n_k)))
|
|
70
|
+
_, logdet_a = np.linalg.slogdet(A)
|
|
71
|
+
result = logdet_v + (n - p) * np.log(rss) + float(logdet_a)
|
|
72
|
+
return np.asarray(result, dtype=np.float64)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fit_random_intercept(
|
|
76
|
+
X: FloatArray,
|
|
77
|
+
Y: FloatArray,
|
|
78
|
+
group_idx: IntArray,
|
|
79
|
+
n_groups: int,
|
|
80
|
+
n_k: FloatArray,
|
|
81
|
+
*,
|
|
82
|
+
eps: float = 1e-8,
|
|
83
|
+
n_grid: int = 80,
|
|
84
|
+
) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray]:
|
|
85
|
+
"""Fit a random-intercept LME per feature by REML.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
X : ndarray of shape (n, p)
|
|
90
|
+
Fixed-effects design matrix (shared across features).
|
|
91
|
+
Y : ndarray of shape (n, n_features)
|
|
92
|
+
Response matrix; one column per feature.
|
|
93
|
+
group_idx : ndarray of shape (n,)
|
|
94
|
+
Integer group (subject) code in ``[0, n_groups)`` for each observation.
|
|
95
|
+
n_groups : int
|
|
96
|
+
Number of distinct subjects.
|
|
97
|
+
n_k : ndarray of shape (n_groups,)
|
|
98
|
+
Number of observations per subject.
|
|
99
|
+
eps : float, default=1e-8
|
|
100
|
+
Numerical jitter; also scales the diagonal ridge on ``X^T W X``.
|
|
101
|
+
n_grid : int, default=80
|
|
102
|
+
Number of log-spaced ``lambda`` grid points for the variance-ratio search.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
beta : ndarray of shape (p, n_features)
|
|
107
|
+
Estimated fixed-effects coefficients.
|
|
108
|
+
blup : ndarray of shape (n_groups, n_features)
|
|
109
|
+
Best linear unbiased predictors of the subject random intercepts.
|
|
110
|
+
sigma2 : ndarray of shape (n_features,)
|
|
111
|
+
Estimated residual variance per feature.
|
|
112
|
+
lam_opt : ndarray of shape (n_features,)
|
|
113
|
+
Estimated variance ratio ``tau^2 / sigma^2`` per feature.
|
|
114
|
+
"""
|
|
115
|
+
X = np.asarray(X, dtype=np.float64)
|
|
116
|
+
Y = np.asarray(Y, dtype=np.float64)
|
|
117
|
+
group_idx = np.asarray(group_idx, dtype=np.intp)
|
|
118
|
+
n_k = np.asarray(n_k, dtype=np.float64)
|
|
119
|
+
n, p = X.shape
|
|
120
|
+
n_features = Y.shape[1]
|
|
121
|
+
ridge = eps * float(np.trace(X.T @ X)) / max(p, 1)
|
|
122
|
+
|
|
123
|
+
# Vectorized grid search for the variance ratio (per feature).
|
|
124
|
+
log_grid = np.linspace(-4.0, 4.0, n_grid)
|
|
125
|
+
lam_grid = np.concatenate([[0.0], 10.0**log_grid])
|
|
126
|
+
fvals = np.empty((lam_grid.size, n_features), dtype=np.float64)
|
|
127
|
+
for i, lam in enumerate(lam_grid):
|
|
128
|
+
fvals[i] = _reml_objective(lam, X, Y, group_idx, n_groups, n_k, n, p, ridge)
|
|
129
|
+
best = np.argmin(fvals, axis=0)
|
|
130
|
+
lam_opt = lam_grid[best].astype(np.float64)
|
|
131
|
+
|
|
132
|
+
# Vectorized parabolic refinement (log10 space) for interior positive optima.
|
|
133
|
+
interior = (best >= 2) & (best <= n_grid - 1)
|
|
134
|
+
if np.any(interior):
|
|
135
|
+
bi = best[interior]
|
|
136
|
+
f_lo = fvals[bi - 1, interior]
|
|
137
|
+
f0 = fvals[bi, interior]
|
|
138
|
+
f_hi = fvals[bi + 1, interior]
|
|
139
|
+
denom = f_lo - 2.0 * f0 + f_hi
|
|
140
|
+
delta = np.where(np.abs(denom) > 1e-12, 0.5 * (f_lo - f_hi) / denom, 0.0)
|
|
141
|
+
delta = np.clip(delta, -1.0, 1.0)
|
|
142
|
+
step = log_grid[1] - log_grid[0]
|
|
143
|
+
lam_opt[interior] = 10.0 ** (log_grid[bi - 1] + delta * step)
|
|
144
|
+
|
|
145
|
+
# Final per-feature fixed effects, residual variance, and random-intercept BLUPs.
|
|
146
|
+
beta = np.empty((p, n_features), dtype=np.float64)
|
|
147
|
+
sigma2 = np.empty(n_features, dtype=np.float64)
|
|
148
|
+
blup = np.zeros((n_groups, n_features), dtype=np.float64)
|
|
149
|
+
denom_sigma = max(n - p, 1)
|
|
150
|
+
for g in range(n_features):
|
|
151
|
+
lam = float(lam_opt[g])
|
|
152
|
+
factor = lam / (1.0 + lam * n_k)
|
|
153
|
+
A = X.T @ _w_apply(X, factor, group_idx, n_groups)
|
|
154
|
+
A.flat[:: p + 1] += ridge
|
|
155
|
+
y = Y[:, g : g + 1]
|
|
156
|
+
b = np.linalg.solve(A, X.T @ _w_apply(y, factor, group_idx, n_groups))[:, 0]
|
|
157
|
+
beta[:, g] = b
|
|
158
|
+
r = Y[:, g] - X @ b
|
|
159
|
+
Wr = _w_apply(r[:, None], factor, group_idx, n_groups)[:, 0]
|
|
160
|
+
sigma2[g] = float(r @ Wr) / denom_sigma
|
|
161
|
+
s_k = np.zeros(n_groups, dtype=np.float64)
|
|
162
|
+
np.add.at(s_k, group_idx, r)
|
|
163
|
+
blup[:, g] = lam * s_k / (1.0 + lam * n_k)
|
|
164
|
+
|
|
165
|
+
return beta, blup, sigma2, lam_opt
|