combatlearn 1.4.0__tar.gz → 2.1.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-1.4.0 → combatlearn-2.1.0}/PKG-INFO +13 -6
- {combatlearn-1.4.0 → combatlearn-2.1.0}/README.md +12 -6
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/__init__.py +1 -1
- combatlearn-2.1.0/combatlearn/_mixed.py +165 -0
- combatlearn-2.1.0/combatlearn/_utils.py +21 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/core.py +243 -10
- combatlearn-2.1.0/combatlearn/inspection.py +194 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/metrics.py +127 -214
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/sklearn_api.py +50 -132
- combatlearn-2.1.0/combatlearn/visualization.py +636 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/PKG-INFO +13 -6
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/SOURCES.txt +8 -1
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/requires.txt +1 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements-dev.txt +1 -0
- combatlearn-2.1.0/tests/test_combat.py +477 -0
- combatlearn-2.1.0/tests/test_inspection.py +235 -0
- combatlearn-2.1.0/tests/test_longitudinal.py +204 -0
- combatlearn-2.1.0/tests/test_metrics.py +107 -0
- combatlearn-2.1.0/tests/test_visualization.py +180 -0
- combatlearn-1.4.0/combatlearn/visualization.py +0 -609
- combatlearn-1.4.0/tests/test_combat.py +0 -1008
- {combatlearn-1.4.0 → combatlearn-2.1.0}/LICENSE +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/py.typed +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/top_level.txt +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/pyproject.toml +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements-docs.txt +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements.txt +0 -0
- {combatlearn-1.4.0 → combatlearn-2.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: combatlearn
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 2.1.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,11 @@ 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
|
+
**Four 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)
|
|
56
58
|
|
|
57
59
|
## Installation
|
|
58
60
|
|
|
@@ -75,6 +77,7 @@ import pandas as pd
|
|
|
75
77
|
from sklearn.pipeline import Pipeline
|
|
76
78
|
from sklearn.preprocessing import StandardScaler
|
|
77
79
|
from sklearn.linear_model import LogisticRegression
|
|
80
|
+
from sklearn.model_selection import GridSearchCV
|
|
78
81
|
from combatlearn import ComBat
|
|
79
82
|
|
|
80
83
|
df = pd.read_csv("data.csv", index_col=0)
|
|
@@ -125,18 +128,20 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
125
128
|
| Parameter | Type | Default | Description |
|
|
126
129
|
| --- | --- | --- | --- |
|
|
127
130
|
| `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 `"
|
|
131
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
|
|
132
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
|
|
133
|
+
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
134
|
+
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
130
135
|
|
|
131
136
|
### Algorithm Options
|
|
132
137
|
|
|
133
138
|
| Parameter | Type | Default | Description |
|
|
134
139
|
| --- | --- | --- | --- |
|
|
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> |
|
|
140
|
+
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)</li></ul> |
|
|
136
141
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
137
142
|
| `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
143
|
| `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
|
|
144
|
+
| `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. |
|
|
140
145
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
141
146
|
|
|
142
147
|
|
|
@@ -190,3 +195,5 @@ Please consider citing the original papers:
|
|
|
190
195
|
- [**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
196
|
|
|
192
197
|
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
198
|
+
|
|
199
|
+
- [**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)
|
|
@@ -12,10 +12,11 @@
|
|
|
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
|
+
**Four 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)
|
|
19
20
|
|
|
20
21
|
## Installation
|
|
21
22
|
|
|
@@ -38,6 +39,7 @@ import pandas as pd
|
|
|
38
39
|
from sklearn.pipeline import Pipeline
|
|
39
40
|
from sklearn.preprocessing import StandardScaler
|
|
40
41
|
from sklearn.linear_model import LogisticRegression
|
|
42
|
+
from sklearn.model_selection import GridSearchCV
|
|
41
43
|
from combatlearn import ComBat
|
|
42
44
|
|
|
43
45
|
df = pd.read_csv("data.csv", index_col=0)
|
|
@@ -88,18 +90,20 @@ The following section provides a detailed explanation of all parameters availabl
|
|
|
88
90
|
| Parameter | Type | Default | Description |
|
|
89
91
|
| --- | --- | --- | --- |
|
|
90
92
|
| `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 `"
|
|
93
|
+
| `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
|
|
94
|
+
| `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
|
|
95
|
+
| `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
|
|
96
|
+
| `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
|
|
93
97
|
|
|
94
98
|
### Algorithm Options
|
|
95
99
|
|
|
96
100
|
| Parameter | Type | Default | Description |
|
|
97
101
|
| --- | --- | --- | --- |
|
|
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> |
|
|
102
|
+
| `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)</li></ul> |
|
|
99
103
|
| `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
|
|
100
104
|
| `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
105
|
| `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
|
|
106
|
+
| `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. |
|
|
103
107
|
| `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
|
|
104
108
|
|
|
105
109
|
|
|
@@ -152,4 +156,6 @@ Please consider citing the original papers:
|
|
|
152
156
|
|
|
153
157
|
- [**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
158
|
|
|
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)
|
|
159
|
+
- [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
|
|
160
|
+
|
|
161
|
+
- [**Longitudinal ComBat**](https://github.com/jcbeer/longCombat) - Beer JC et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2020.117129](https://doi.org/10.1016/j.neuroimage.2020.117129)
|
|
@@ -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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Internal utilities shared across combatlearn modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .core import ArrayLike
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
|
|
12
|
+
"""Subset array-like object by index."""
|
|
13
|
+
if obj is None:
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(obj, pd.Series | pd.DataFrame):
|
|
16
|
+
return obj.loc[idx]
|
|
17
|
+
else:
|
|
18
|
+
if isinstance(obj, np.ndarray) and obj.ndim == 1:
|
|
19
|
+
return pd.Series(obj, index=idx)
|
|
20
|
+
else:
|
|
21
|
+
return pd.DataFrame(obj, index=idx)
|