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.
Files changed (29) hide show
  1. {combatlearn-2.0.0 → combatlearn-2.2.0}/PKG-INFO +21 -6
  2. {combatlearn-2.0.0 → combatlearn-2.2.0}/README.md +20 -6
  3. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/__init__.py +1 -1
  4. combatlearn-2.2.0/combatlearn/_mixed.py +165 -0
  5. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/core.py +527 -22
  6. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/sklearn_api.py +75 -8
  7. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/visualization.py +105 -49
  8. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/PKG-INFO +21 -6
  9. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/SOURCES.txt +4 -0
  10. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/requires.txt +1 -0
  11. {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements-dev.txt +1 -0
  12. {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_combat.py +46 -0
  13. combatlearn-2.2.0/tests/test_eb_degenerate.py +111 -0
  14. combatlearn-2.2.0/tests/test_gam.py +298 -0
  15. combatlearn-2.2.0/tests/test_longitudinal.py +216 -0
  16. {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_visualization.py +35 -0
  17. {combatlearn-2.0.0 → combatlearn-2.2.0}/LICENSE +0 -0
  18. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/_utils.py +0 -0
  19. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/inspection.py +0 -0
  20. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/metrics.py +0 -0
  21. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn/py.typed +0 -0
  22. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  23. {combatlearn-2.0.0 → combatlearn-2.2.0}/combatlearn.egg-info/top_level.txt +0 -0
  24. {combatlearn-2.0.0 → combatlearn-2.2.0}/pyproject.toml +0 -0
  25. {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements-docs.txt +0 -0
  26. {combatlearn-2.0.0 → combatlearn-2.2.0}/requirements.txt +0 -0
  27. {combatlearn-2.0.0 → combatlearn-2.2.0}/setup.cfg +0 -0
  28. {combatlearn-2.0.0 → combatlearn-2.2.0}/tests/test_inspection.py +0 -0
  29. {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.0.0
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
- **Three methods**:
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 `"chen"` methods. |
129
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"chen"` methods. |
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 $]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. |
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
- **Three methods**:
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 `"chen"` methods. |
92
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"chen"` methods. |
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 $]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. |
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)
@@ -1,5 +1,5 @@
1
1
  from .sklearn_api import ComBat
2
2
 
3
3
  __all__ = ["ComBat"]
4
- __version__ = "2.0.0"
4
+ __version__ = "2.2.0"
5
5
  __author__ = "Ettore Rocchi"
@@ -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