combatlearn 1.3.0__tar.gz → 2.0.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.3.0 → combatlearn-2.0.0}/PKG-INFO +4 -1
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/__init__.py +1 -1
- combatlearn-2.0.0/combatlearn/_utils.py +21 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/core.py +239 -83
- combatlearn-2.0.0/combatlearn/inspection.py +194 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/metrics.py +256 -310
- combatlearn-2.0.0/combatlearn/py.typed +0 -0
- combatlearn-2.0.0/combatlearn/sklearn_api.py +295 -0
- combatlearn-2.0.0/combatlearn/visualization.py +636 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/PKG-INFO +4 -1
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/SOURCES.txt +9 -3
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/requires.txt +3 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/pyproject.toml +19 -2
- combatlearn-2.0.0/requirements-dev.txt +6 -0
- combatlearn-2.0.0/tests/test_combat.py +477 -0
- combatlearn-2.0.0/tests/test_inspection.py +235 -0
- combatlearn-2.0.0/tests/test_metrics.py +107 -0
- combatlearn-2.0.0/tests/test_visualization.py +180 -0
- combatlearn-1.3.0/combatlearn/sklearn_api.py +0 -277
- combatlearn-1.3.0/combatlearn/visualization.py +0 -643
- combatlearn-1.3.0/dev_requirements.txt +0 -3
- combatlearn-1.3.0/tests/test_combat.py +0 -851
- {combatlearn-1.3.0 → combatlearn-2.0.0}/LICENSE +0 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/README.md +0 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/top_level.txt +0 -0
- /combatlearn-1.3.0/docs_requirements.txt → /combatlearn-2.0.0/requirements-docs.txt +0 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/requirements.txt +0 -0
- {combatlearn-1.3.0 → combatlearn-2.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: combatlearn
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Batch-effect harmonization for machine learning frameworks.
|
|
5
5
|
Author-email: Ettore Rocchi <ettoreroc@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -22,8 +22,11 @@ Requires-Dist: nbformat>=4.2
|
|
|
22
22
|
Requires-Dist: umap-learn>=0.5
|
|
23
23
|
Provides-Extra: dev
|
|
24
24
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
25
26
|
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
26
27
|
Requires-Dist: pre-commit>=3.0; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pandas-stubs; extra == "dev"
|
|
27
30
|
Provides-Extra: docs
|
|
28
31
|
Requires-Dist: sphinx>=7.0.0; extra == "docs"
|
|
29
32
|
Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
|
|
@@ -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)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import warnings
|
|
12
|
-
from typing import Any, Literal
|
|
12
|
+
from typing import Any, Literal, TypeAlias
|
|
13
13
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
import numpy.linalg as la
|
|
@@ -17,8 +17,8 @@ import numpy.typing as npt
|
|
|
17
17
|
import pandas as pd
|
|
18
18
|
from sklearn.decomposition import PCA
|
|
19
19
|
|
|
20
|
-
ArrayLike = pd.DataFrame | pd.Series | npt.NDArray[Any]
|
|
21
|
-
FloatArray = npt.NDArray[np.float64]
|
|
20
|
+
ArrayLike: TypeAlias = pd.DataFrame | pd.Series | npt.NDArray[Any]
|
|
21
|
+
FloatArray: TypeAlias = npt.NDArray[np.float64]
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class ComBatModel:
|
|
@@ -69,6 +69,7 @@ class ComBatModel:
|
|
|
69
69
|
self._n_per_batch: dict[str, int]
|
|
70
70
|
self._reference_batch_idx: int | None
|
|
71
71
|
self._beta_hat_nonbatch: FloatArray
|
|
72
|
+
self._nonbatch_columns: list[str]
|
|
72
73
|
self._n_batch: int
|
|
73
74
|
self._p_design: int
|
|
74
75
|
self._covbat_pca: PCA
|
|
@@ -80,19 +81,35 @@ class ComBatModel:
|
|
|
80
81
|
# Validate covbat_cov_thresh
|
|
81
82
|
if isinstance(self.covbat_cov_thresh, float):
|
|
82
83
|
if not (0.0 < self.covbat_cov_thresh <= 1.0):
|
|
83
|
-
raise ValueError(
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"covbat_cov_thresh={self.covbat_cov_thresh!r} is out of range. "
|
|
86
|
+
f"Expected a float in (0, 1] (e.g. 0.9 to retain 90% of variance)."
|
|
87
|
+
)
|
|
84
88
|
elif isinstance(self.covbat_cov_thresh, int):
|
|
85
89
|
if self.covbat_cov_thresh < 1:
|
|
86
|
-
raise ValueError(
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"covbat_cov_thresh={self.covbat_cov_thresh!r} is invalid. "
|
|
92
|
+
f"Expected an int >= 1 (the number of principal components to retain)."
|
|
93
|
+
)
|
|
87
94
|
else:
|
|
88
|
-
raise TypeError(
|
|
95
|
+
raise TypeError(
|
|
96
|
+
f"covbat_cov_thresh must be float or int, "
|
|
97
|
+
f"got {type(self.covbat_cov_thresh).__name__} ({self.covbat_cov_thresh!r}). "
|
|
98
|
+
f"Use a float in (0, 1] for cumulative variance or an int >= 1 for "
|
|
99
|
+
f"a fixed number of PCs."
|
|
100
|
+
)
|
|
89
101
|
|
|
90
102
|
@staticmethod
|
|
91
103
|
def _as_series(arr: ArrayLike, index: pd.Index, name: str) -> pd.Series:
|
|
92
104
|
"""Convert array-like to categorical Series with validation."""
|
|
93
105
|
ser = arr.copy() if isinstance(arr, pd.Series) else pd.Series(arr, index=index, name=name)
|
|
94
106
|
if not ser.index.equals(index):
|
|
95
|
-
|
|
107
|
+
diff = index.symmetric_difference(ser.index)
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"`{name}` index does not align with `X`. "
|
|
110
|
+
f"{len(diff)} mismatched entries (first 3): {list(diff[:3])}. "
|
|
111
|
+
f"Ensure both share the same index."
|
|
112
|
+
)
|
|
96
113
|
return ser.astype("category")
|
|
97
114
|
|
|
98
115
|
@staticmethod
|
|
@@ -105,7 +122,12 @@ class ComBatModel:
|
|
|
105
122
|
if not isinstance(arr, pd.DataFrame):
|
|
106
123
|
arr = pd.DataFrame(arr, index=index)
|
|
107
124
|
if not arr.index.equals(index):
|
|
108
|
-
|
|
125
|
+
diff = index.symmetric_difference(arr.index)
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"`{name}` index does not align with `X`. "
|
|
128
|
+
f"{len(diff)} mismatched entries (first 3): {list(diff[:3])}. "
|
|
129
|
+
f"Ensure both share the same index."
|
|
130
|
+
)
|
|
109
131
|
return arr
|
|
110
132
|
|
|
111
133
|
def fit(
|
|
@@ -139,7 +161,10 @@ class ComBatModel:
|
|
|
139
161
|
"""
|
|
140
162
|
method = self.method.lower()
|
|
141
163
|
if method not in {"johnson", "fortin", "chen"}:
|
|
142
|
-
raise ValueError(
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"method={self.method!r} is not recognized. "
|
|
166
|
+
f"Expected one of 'johnson', 'fortin', or 'chen'."
|
|
167
|
+
)
|
|
143
168
|
if not isinstance(X, pd.DataFrame):
|
|
144
169
|
X = pd.DataFrame(X)
|
|
145
170
|
idx = X.index
|
|
@@ -150,8 +175,9 @@ class ComBatModel:
|
|
|
150
175
|
|
|
151
176
|
if self.reference_batch is not None and self.reference_batch not in batch.cat.categories:
|
|
152
177
|
raise ValueError(
|
|
153
|
-
f"reference_batch={self.reference_batch!r} not
|
|
154
|
-
f"{list(batch.cat.categories)}"
|
|
178
|
+
f"reference_batch={self.reference_batch!r} not found. "
|
|
179
|
+
f"Available batches: {list(batch.cat.categories)}. "
|
|
180
|
+
f"Check for typos or trailing whitespace in batch labels."
|
|
155
181
|
)
|
|
156
182
|
|
|
157
183
|
if method == "johnson":
|
|
@@ -167,9 +193,16 @@ class ComBatModel:
|
|
|
167
193
|
def _fit_johnson(self, X: pd.DataFrame, batch: pd.Series) -> None:
|
|
168
194
|
"""Johnson et al. (2007) ComBat."""
|
|
169
195
|
self._batch_levels = batch.cat.categories
|
|
170
|
-
pooled_var = X.var(axis=0, ddof=1) + self.eps
|
|
171
196
|
grand_mean = X.mean(axis=0)
|
|
172
197
|
|
|
198
|
+
# Within-batch residual variance
|
|
199
|
+
n_samples = len(X)
|
|
200
|
+
resid = X.copy()
|
|
201
|
+
for lvl in self._batch_levels:
|
|
202
|
+
idx = batch == lvl
|
|
203
|
+
resid.loc[idx] = X.loc[idx] - X.loc[idx].mean(axis=0)
|
|
204
|
+
pooled_var = (resid**2).sum(axis=0) / n_samples + self.eps
|
|
205
|
+
|
|
173
206
|
Xs = (X - grand_mean) / np.sqrt(pooled_var)
|
|
174
207
|
|
|
175
208
|
n_per_batch: dict[str, int] = {}
|
|
@@ -180,7 +213,10 @@ class ComBatModel:
|
|
|
180
213
|
idx = batch == lvl
|
|
181
214
|
n_b = int(idx.sum())
|
|
182
215
|
if n_b < 2:
|
|
183
|
-
raise ValueError(
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"Batch '{lvl}' has only {n_b} sample(s). ComBat requires >= 2 samples "
|
|
218
|
+
f"per batch. Consider merging small batches or removing them."
|
|
219
|
+
)
|
|
184
220
|
n_per_batch[str(lvl)] = n_b
|
|
185
221
|
xb = Xs.loc[idx]
|
|
186
222
|
gamma_hat.append(xb.mean(axis=0).values)
|
|
@@ -189,14 +225,23 @@ class ComBatModel:
|
|
|
189
225
|
gamma_hat_arr = np.vstack(gamma_hat)
|
|
190
226
|
delta_hat_arr = np.vstack(delta_hat)
|
|
191
227
|
|
|
228
|
+
b_names = [str(lvl) for lvl in self._batch_levels]
|
|
192
229
|
if self.mean_only:
|
|
193
230
|
gamma_star = self._shrink_gamma(
|
|
194
|
-
gamma_hat_arr,
|
|
231
|
+
gamma_hat_arr,
|
|
232
|
+
delta_hat_arr,
|
|
233
|
+
n_per_batch,
|
|
234
|
+
parametric=self.parametric,
|
|
235
|
+
batch_names=b_names,
|
|
195
236
|
)
|
|
196
237
|
delta_star = np.ones_like(delta_hat_arr)
|
|
197
238
|
else:
|
|
198
239
|
gamma_star, delta_star = self._shrink_gamma_delta(
|
|
199
|
-
gamma_hat_arr,
|
|
240
|
+
gamma_hat_arr,
|
|
241
|
+
delta_hat_arr,
|
|
242
|
+
n_per_batch,
|
|
243
|
+
parametric=self.parametric,
|
|
244
|
+
batch_names=b_names,
|
|
200
245
|
)
|
|
201
246
|
|
|
202
247
|
if self.reference_batch is not None:
|
|
@@ -215,6 +260,7 @@ class ComBatModel:
|
|
|
215
260
|
self._gamma_star = gamma_star
|
|
216
261
|
self._delta_star = delta_star
|
|
217
262
|
self._n_per_batch = n_per_batch
|
|
263
|
+
self._design_cond: float | None = None
|
|
218
264
|
|
|
219
265
|
def _fit_fortin(
|
|
220
266
|
self,
|
|
@@ -232,8 +278,8 @@ class ComBatModel:
|
|
|
232
278
|
if self.reference_batch is not None:
|
|
233
279
|
if self.reference_batch not in self._batch_levels:
|
|
234
280
|
raise ValueError(
|
|
235
|
-
f"reference_batch={self.reference_batch!r} not
|
|
236
|
-
f"{list(self._batch_levels)}"
|
|
281
|
+
f"reference_batch={self.reference_batch!r} not found. "
|
|
282
|
+
f"Available batches: {list(self._batch_levels)}. "
|
|
237
283
|
)
|
|
238
284
|
batch_dummies.loc[:, self.reference_batch] = 1.0
|
|
239
285
|
|
|
@@ -244,7 +290,10 @@ class ComBatModel:
|
|
|
244
290
|
if cont is not None:
|
|
245
291
|
parts.append(cont.astype(float))
|
|
246
292
|
|
|
247
|
-
|
|
293
|
+
design_df = pd.concat(parts, axis=1)
|
|
294
|
+
self._nonbatch_columns = design_df.columns[n_batch:].tolist()
|
|
295
|
+
design = design_df.values
|
|
296
|
+
self._design_cond = float(la.cond(design))
|
|
248
297
|
p_design = design.shape[1]
|
|
249
298
|
|
|
250
299
|
X_np = X.values
|
|
@@ -253,8 +302,10 @@ class ComBatModel:
|
|
|
253
302
|
beta_hat_batch = beta_hat[:n_batch]
|
|
254
303
|
self._beta_hat_nonbatch = beta_hat[n_batch:]
|
|
255
304
|
|
|
256
|
-
n_per_batch =
|
|
257
|
-
|
|
305
|
+
n_per_batch: FloatArray = np.asarray(
|
|
306
|
+
batch.value_counts().sort_index().astype(int).values, dtype=np.float64
|
|
307
|
+
)
|
|
308
|
+
self._n_per_batch = dict(zip(self._batch_levels, n_per_batch.astype(int), strict=True))
|
|
258
309
|
|
|
259
310
|
if self.reference_batch is not None:
|
|
260
311
|
ref_idx = list(self._batch_levels).index(self.reference_batch)
|
|
@@ -266,7 +317,7 @@ class ComBatModel:
|
|
|
266
317
|
self._grand_mean = pd.Series(grand_mean, index=X.columns)
|
|
267
318
|
|
|
268
319
|
if self.reference_batch is not None:
|
|
269
|
-
ref_mask = (batch == self.reference_batch)
|
|
320
|
+
ref_mask = np.asarray(batch == self.reference_batch)
|
|
270
321
|
resid = X_np[ref_mask] - design[ref_mask] @ beta_hat
|
|
271
322
|
denom = int(ref_mask.sum())
|
|
272
323
|
else:
|
|
@@ -283,14 +334,23 @@ class ComBatModel:
|
|
|
283
334
|
[Xs[batch == lvl].var(axis=0, ddof=1) + self.eps for lvl in self._batch_levels]
|
|
284
335
|
)
|
|
285
336
|
|
|
337
|
+
b_names = [str(lvl) for lvl in self._batch_levels]
|
|
286
338
|
if self.mean_only:
|
|
287
339
|
gamma_star = self._shrink_gamma(
|
|
288
|
-
gamma_hat,
|
|
340
|
+
gamma_hat,
|
|
341
|
+
delta_hat,
|
|
342
|
+
n_per_batch,
|
|
343
|
+
parametric=self.parametric,
|
|
344
|
+
batch_names=b_names,
|
|
289
345
|
)
|
|
290
346
|
delta_star = np.ones_like(delta_hat)
|
|
291
347
|
else:
|
|
292
348
|
gamma_star, delta_star = self._shrink_gamma_delta(
|
|
293
|
-
gamma_hat,
|
|
349
|
+
gamma_hat,
|
|
350
|
+
delta_hat,
|
|
351
|
+
n_per_batch,
|
|
352
|
+
parametric=self.parametric,
|
|
353
|
+
batch_names=b_names,
|
|
294
354
|
)
|
|
295
355
|
|
|
296
356
|
if ref_idx is not None:
|
|
@@ -341,14 +401,23 @@ class ComBatModel:
|
|
|
341
401
|
gamma_hat_arr = np.vstack(gamma_hat)
|
|
342
402
|
delta_hat_arr = np.vstack(delta_hat)
|
|
343
403
|
|
|
404
|
+
b_names = [str(lvl) for lvl in self._batch_levels_pc]
|
|
344
405
|
if self.mean_only:
|
|
345
406
|
gamma_star = self._shrink_gamma(
|
|
346
|
-
gamma_hat_arr,
|
|
407
|
+
gamma_hat_arr,
|
|
408
|
+
delta_hat_arr,
|
|
409
|
+
n_per_batch,
|
|
410
|
+
parametric=self.parametric,
|
|
411
|
+
batch_names=b_names,
|
|
347
412
|
)
|
|
348
413
|
delta_star = np.ones_like(delta_hat_arr)
|
|
349
414
|
else:
|
|
350
415
|
gamma_star, delta_star = self._shrink_gamma_delta(
|
|
351
|
-
gamma_hat_arr,
|
|
416
|
+
gamma_hat_arr,
|
|
417
|
+
delta_hat_arr,
|
|
418
|
+
n_per_batch,
|
|
419
|
+
parametric=self.parametric,
|
|
420
|
+
batch_names=b_names,
|
|
352
421
|
)
|
|
353
422
|
|
|
354
423
|
if self.reference_batch is not None:
|
|
@@ -371,64 +440,105 @@ class ComBatModel:
|
|
|
371
440
|
parametric: bool,
|
|
372
441
|
max_iter: int = 100,
|
|
373
442
|
tol: float = 1e-4,
|
|
443
|
+
batch_names: list[str] | None = None,
|
|
374
444
|
) -> tuple[FloatArray, FloatArray]:
|
|
375
|
-
"""Empirical Bayes shrinkage estimation.
|
|
445
|
+
"""Empirical Bayes shrinkage estimation.
|
|
446
|
+
|
|
447
|
+
Both the parametric and non-parametric branches use an iterative
|
|
448
|
+
scheme that alternates between updating gamma (location) and delta
|
|
449
|
+
(scale) posteriors until convergence.
|
|
450
|
+
"""
|
|
451
|
+
B, _p = gamma_hat.shape
|
|
452
|
+
n_vec = (
|
|
453
|
+
np.array(list(n_per_batch.values())) if isinstance(n_per_batch, dict) else n_per_batch
|
|
454
|
+
)
|
|
455
|
+
gamma_bar = gamma_hat.mean(axis=0)
|
|
456
|
+
t2 = np.maximum(gamma_hat.var(axis=0, ddof=1), self.eps)
|
|
457
|
+
convergence_info: list[dict[str, object]] = []
|
|
458
|
+
|
|
459
|
+
def postmean(
|
|
460
|
+
g_hat: FloatArray,
|
|
461
|
+
g_bar: FloatArray,
|
|
462
|
+
n: float,
|
|
463
|
+
d_star: FloatArray,
|
|
464
|
+
t2_: FloatArray,
|
|
465
|
+
) -> FloatArray:
|
|
466
|
+
return (t2_ * n * g_hat + d_star * g_bar) / (t2_ * n + d_star)
|
|
467
|
+
|
|
468
|
+
def postvar(
|
|
469
|
+
sum2: FloatArray, n: float, a: FloatArray | float, b: FloatArray | float
|
|
470
|
+
) -> FloatArray:
|
|
471
|
+
return (0.5 * sum2 + b) / (n / 2.0 + a - 1.0)
|
|
472
|
+
|
|
376
473
|
if parametric:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
a_prior = (delta_hat.mean(axis=0) ** 2) / delta_hat.var(axis=0, ddof=1) + 2
|
|
474
|
+
delta_var = np.maximum(delta_hat.var(axis=0, ddof=1), self.eps)
|
|
475
|
+
a_prior = (delta_hat.mean(axis=0) ** 2) / delta_var + 2
|
|
380
476
|
b_prior = delta_hat.mean(axis=0) * (a_prior - 1)
|
|
381
477
|
|
|
382
|
-
B, _p = gamma_hat.shape
|
|
383
478
|
gamma_star = np.empty_like(gamma_hat)
|
|
384
479
|
delta_star = np.empty_like(delta_hat)
|
|
385
|
-
n_vec = (
|
|
386
|
-
np.array(list(n_per_batch.values()))
|
|
387
|
-
if isinstance(n_per_batch, dict)
|
|
388
|
-
else n_per_batch
|
|
389
|
-
)
|
|
390
480
|
|
|
391
481
|
for i in range(B):
|
|
392
482
|
n_i = n_vec[i]
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
483
|
+
g_hat_i = gamma_hat[i]
|
|
484
|
+
d_hat_i = np.maximum(delta_hat[i], self.eps)
|
|
485
|
+
|
|
486
|
+
g_new = postmean(g_hat_i, gamma_bar, n_i, d_hat_i, t2)
|
|
487
|
+
sum2 = (n_i - 1) * d_hat_i + n_i * (g_hat_i - g_new) ** 2
|
|
488
|
+
d_new = postvar(sum2, n_i, a_prior, b_prior)
|
|
396
489
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
490
|
+
converged = False
|
|
491
|
+
n_iter = 0
|
|
492
|
+
gamma_change = float("inf")
|
|
493
|
+
delta_change = float("inf")
|
|
494
|
+
for n_iter in range(1, max_iter + 1): # noqa: B007 # used after loop
|
|
495
|
+
g_prev, d_prev = g_new, d_new
|
|
496
|
+
g_new = postmean(g_hat_i, gamma_bar, n_i, d_prev, t2)
|
|
497
|
+
sum2 = (n_i - 1) * d_hat_i + n_i * (g_hat_i - g_new) ** 2
|
|
498
|
+
d_new = postvar(sum2, n_i, a_prior, b_prior)
|
|
499
|
+
gamma_change = float(
|
|
500
|
+
np.max(np.abs(g_new - g_prev) / (np.abs(g_prev) + self.eps))
|
|
501
|
+
)
|
|
502
|
+
delta_change = float(
|
|
503
|
+
np.max(np.abs(d_new - d_prev) / (np.abs(d_prev) + self.eps))
|
|
504
|
+
)
|
|
505
|
+
if gamma_change < tol and (self.mean_only or delta_change < tol):
|
|
506
|
+
converged = True
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
b_name = batch_names[i] if batch_names else str(i)
|
|
510
|
+
if not converged:
|
|
511
|
+
warnings.warn(
|
|
512
|
+
f"Parametric EB did not converge for batch '{b_name}' "
|
|
513
|
+
f"within {max_iter} iterations "
|
|
514
|
+
f"(final relative change: {max(gamma_change, delta_change):.2e}, "
|
|
515
|
+
f"tol: {tol:.1e}). Results may be unreliable for this batch.",
|
|
516
|
+
stacklevel=2,
|
|
517
|
+
)
|
|
518
|
+
convergence_info.append(
|
|
519
|
+
{
|
|
520
|
+
"batch": b_name,
|
|
521
|
+
"converged": converged,
|
|
522
|
+
"iterations": n_iter,
|
|
523
|
+
"final_gamma_change": gamma_change,
|
|
524
|
+
"final_delta_change": delta_change,
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
gamma_star[i] = g_new
|
|
528
|
+
delta_star[i] = 1.0 if self.mean_only else d_new
|
|
529
|
+
self._convergence_info = convergence_info
|
|
400
530
|
return gamma_star, delta_star
|
|
401
531
|
|
|
402
532
|
else:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
else n_per_batch
|
|
408
|
-
)
|
|
409
|
-
gamma_bar = gamma_hat.mean(axis=0)
|
|
410
|
-
t2 = gamma_hat.var(axis=0, ddof=1)
|
|
411
|
-
|
|
412
|
-
def postmean(
|
|
413
|
-
g_hat: FloatArray,
|
|
414
|
-
g_bar: FloatArray,
|
|
415
|
-
n: float,
|
|
416
|
-
d_star: FloatArray,
|
|
417
|
-
t2_: FloatArray,
|
|
418
|
-
) -> FloatArray:
|
|
419
|
-
return (t2_ * n * g_hat + d_star * g_bar) / (t2_ * n + d_star)
|
|
420
|
-
|
|
421
|
-
def postvar(sum2: FloatArray, n: float, a: FloatArray, b: FloatArray) -> FloatArray:
|
|
422
|
-
return (0.5 * sum2 + b) / (n / 2.0 + a - 1.0)
|
|
423
|
-
|
|
424
|
-
def aprior(delta: FloatArray) -> FloatArray:
|
|
425
|
-
m, s2 = delta.mean(), delta.var()
|
|
426
|
-
s2 = max(s2, self.eps)
|
|
533
|
+
|
|
534
|
+
def aprior(delta: FloatArray) -> float:
|
|
535
|
+
m = float(delta.mean())
|
|
536
|
+
s2 = float(max(delta.var(ddof=1), self.eps))
|
|
427
537
|
return (2 * s2 + m**2) / s2
|
|
428
538
|
|
|
429
|
-
def bprior(delta: FloatArray) ->
|
|
430
|
-
m
|
|
431
|
-
s2 = max(
|
|
539
|
+
def bprior(delta: FloatArray) -> float:
|
|
540
|
+
m = float(delta.mean())
|
|
541
|
+
s2 = float(max(delta.var(ddof=1), self.eps))
|
|
432
542
|
return (m * s2 + m**3) / s2
|
|
433
543
|
|
|
434
544
|
gamma_star = np.empty_like(gamma_hat)
|
|
@@ -441,19 +551,47 @@ class ComBatModel:
|
|
|
441
551
|
a_i = aprior(d_hat_i)
|
|
442
552
|
b_i = bprior(d_hat_i)
|
|
443
553
|
|
|
554
|
+
converged = False
|
|
555
|
+
n_iter = 0
|
|
556
|
+
gamma_change = float("inf")
|
|
557
|
+
delta_change = float("inf")
|
|
444
558
|
g_new, d_new = g_hat_i.copy(), d_hat_i.copy()
|
|
445
|
-
for
|
|
559
|
+
for n_iter in range(1, max_iter + 1): # noqa: B007 # used after loop
|
|
446
560
|
g_prev, d_prev = g_new, d_new
|
|
447
561
|
g_new = postmean(g_hat_i, gamma_bar, n_i, d_prev, t2)
|
|
448
562
|
sum2 = (n_i - 1) * d_hat_i + n_i * (g_hat_i - g_new) ** 2
|
|
449
563
|
d_new = postvar(sum2, n_i, a_i, b_i)
|
|
450
|
-
|
|
451
|
-
self.
|
|
452
|
-
|
|
453
|
-
|
|
564
|
+
gamma_change = float(
|
|
565
|
+
np.max(np.abs(g_new - g_prev) / (np.abs(g_prev) + self.eps))
|
|
566
|
+
)
|
|
567
|
+
delta_change = float(
|
|
568
|
+
np.max(np.abs(d_new - d_prev) / (np.abs(d_prev) + self.eps))
|
|
569
|
+
)
|
|
570
|
+
if gamma_change < tol and (self.mean_only or delta_change < tol):
|
|
571
|
+
converged = True
|
|
454
572
|
break
|
|
573
|
+
|
|
574
|
+
b_name = batch_names[i] if batch_names else str(i)
|
|
575
|
+
if not converged:
|
|
576
|
+
warnings.warn(
|
|
577
|
+
f"Non-parametric EB did not converge for batch '{b_name}' "
|
|
578
|
+
f"within {max_iter} iterations "
|
|
579
|
+
f"(final relative change: {max(gamma_change, delta_change):.2e}, "
|
|
580
|
+
f"tol: {tol:.1e}). Results may be unreliable for this batch.",
|
|
581
|
+
stacklevel=2,
|
|
582
|
+
)
|
|
583
|
+
convergence_info.append(
|
|
584
|
+
{
|
|
585
|
+
"batch": b_name,
|
|
586
|
+
"converged": converged,
|
|
587
|
+
"iterations": n_iter,
|
|
588
|
+
"final_gamma_change": gamma_change,
|
|
589
|
+
"final_delta_change": delta_change,
|
|
590
|
+
}
|
|
591
|
+
)
|
|
455
592
|
gamma_star[i] = g_new
|
|
456
593
|
delta_star[i] = 1.0 if self.mean_only else d_new
|
|
594
|
+
self._convergence_info = convergence_info
|
|
457
595
|
return gamma_star, delta_star
|
|
458
596
|
|
|
459
597
|
def _shrink_gamma(
|
|
@@ -463,10 +601,15 @@ class ComBatModel:
|
|
|
463
601
|
n_per_batch: dict[str, int] | FloatArray,
|
|
464
602
|
*,
|
|
465
603
|
parametric: bool,
|
|
604
|
+
batch_names: list[str] | None = None,
|
|
466
605
|
) -> FloatArray:
|
|
467
606
|
"""Convenience wrapper that returns only gamma* (for *mean-only* mode)."""
|
|
468
607
|
gamma, _ = self._shrink_gamma_delta(
|
|
469
|
-
gamma_hat,
|
|
608
|
+
gamma_hat,
|
|
609
|
+
delta_hat,
|
|
610
|
+
n_per_batch,
|
|
611
|
+
parametric=parametric,
|
|
612
|
+
batch_names=batch_names,
|
|
470
613
|
)
|
|
471
614
|
return gamma
|
|
472
615
|
|
|
@@ -523,7 +666,9 @@ class ComBatModel:
|
|
|
523
666
|
elif method == "chen":
|
|
524
667
|
return self._transform_chen(X, batch, disc, cont)
|
|
525
668
|
else:
|
|
526
|
-
raise ValueError(
|
|
669
|
+
raise ValueError(
|
|
670
|
+
f"Unknown method={method!r}. Expected one of 'johnson', 'fortin', or 'chen'."
|
|
671
|
+
)
|
|
527
672
|
|
|
528
673
|
def _transform_johnson(self, X: pd.DataFrame, batch: pd.Series) -> pd.DataFrame:
|
|
529
674
|
"""Johnson transform implementation."""
|
|
@@ -555,21 +700,32 @@ class ComBatModel:
|
|
|
555
700
|
cont: pd.DataFrame | None,
|
|
556
701
|
) -> pd.DataFrame:
|
|
557
702
|
"""Fortin transform implementation."""
|
|
558
|
-
batch_dummies = pd.get_dummies(batch, drop_first=False).astype(float)
|
|
703
|
+
batch_dummies = pd.get_dummies(batch, drop_first=False).astype(float)
|
|
704
|
+
batch_dummies = batch_dummies.reindex(columns=self._batch_levels, fill_value=0.0)
|
|
559
705
|
if self.reference_batch is not None:
|
|
560
706
|
batch_dummies.loc[:, self.reference_batch] = 1.0
|
|
561
707
|
|
|
562
|
-
|
|
708
|
+
nonbatch_parts: list[pd.DataFrame] = []
|
|
563
709
|
if disc is not None:
|
|
564
|
-
|
|
710
|
+
nonbatch_parts.append(
|
|
711
|
+
pd.get_dummies(disc.astype("category"), drop_first=True).astype(float)
|
|
712
|
+
)
|
|
565
713
|
if cont is not None:
|
|
566
|
-
|
|
714
|
+
nonbatch_parts.append(cont.astype(float))
|
|
715
|
+
|
|
716
|
+
if nonbatch_parts:
|
|
717
|
+
nonbatch_df = pd.concat(nonbatch_parts, axis=1)
|
|
718
|
+
else:
|
|
719
|
+
nonbatch_df = pd.DataFrame(index=batch_dummies.index)
|
|
720
|
+
nonbatch_df = nonbatch_df.reindex(columns=self._nonbatch_columns, fill_value=0.0)
|
|
567
721
|
|
|
568
|
-
design = pd.concat(
|
|
722
|
+
design = pd.concat([batch_dummies, nonbatch_df], axis=1).values
|
|
569
723
|
|
|
570
724
|
X_np = X.values
|
|
571
|
-
|
|
572
|
-
|
|
725
|
+
grand_vals = np.asarray(self._grand_mean.values, dtype=np.float64)
|
|
726
|
+
pooled_vals = np.asarray(self._pooled_var.values, dtype=np.float64)
|
|
727
|
+
stand_mu = grand_vals + design[:, self._n_batch :] @ self._beta_hat_nonbatch
|
|
728
|
+
Xs = (X_np - stand_mu) / np.sqrt(pooled_vals)
|
|
573
729
|
|
|
574
730
|
for i, lvl in enumerate(self._batch_levels):
|
|
575
731
|
idx = batch == lvl
|
|
@@ -586,7 +742,7 @@ class ComBatModel:
|
|
|
586
742
|
else:
|
|
587
743
|
Xs[idx] = (Xs[idx] - g) / np.sqrt(d)
|
|
588
744
|
|
|
589
|
-
X_adj = Xs * np.sqrt(
|
|
745
|
+
X_adj = Xs * np.sqrt(pooled_vals) + stand_mu
|
|
590
746
|
return pd.DataFrame(X_adj, index=X.index, columns=X.columns, dtype=float)
|
|
591
747
|
|
|
592
748
|
def _transform_chen(
|