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.
Files changed (29) hide show
  1. {combatlearn-1.3.0 → combatlearn-2.0.0}/PKG-INFO +4 -1
  2. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/__init__.py +1 -1
  3. combatlearn-2.0.0/combatlearn/_utils.py +21 -0
  4. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/core.py +239 -83
  5. combatlearn-2.0.0/combatlearn/inspection.py +194 -0
  6. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn/metrics.py +256 -310
  7. combatlearn-2.0.0/combatlearn/py.typed +0 -0
  8. combatlearn-2.0.0/combatlearn/sklearn_api.py +295 -0
  9. combatlearn-2.0.0/combatlearn/visualization.py +636 -0
  10. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/PKG-INFO +4 -1
  11. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/SOURCES.txt +9 -3
  12. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/requires.txt +3 -0
  13. {combatlearn-1.3.0 → combatlearn-2.0.0}/pyproject.toml +19 -2
  14. combatlearn-2.0.0/requirements-dev.txt +6 -0
  15. combatlearn-2.0.0/tests/test_combat.py +477 -0
  16. combatlearn-2.0.0/tests/test_inspection.py +235 -0
  17. combatlearn-2.0.0/tests/test_metrics.py +107 -0
  18. combatlearn-2.0.0/tests/test_visualization.py +180 -0
  19. combatlearn-1.3.0/combatlearn/sklearn_api.py +0 -277
  20. combatlearn-1.3.0/combatlearn/visualization.py +0 -643
  21. combatlearn-1.3.0/dev_requirements.txt +0 -3
  22. combatlearn-1.3.0/tests/test_combat.py +0 -851
  23. {combatlearn-1.3.0 → combatlearn-2.0.0}/LICENSE +0 -0
  24. {combatlearn-1.3.0 → combatlearn-2.0.0}/README.md +0 -0
  25. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  26. {combatlearn-1.3.0 → combatlearn-2.0.0}/combatlearn.egg-info/top_level.txt +0 -0
  27. /combatlearn-1.3.0/docs_requirements.txt → /combatlearn-2.0.0/requirements-docs.txt +0 -0
  28. {combatlearn-1.3.0 → combatlearn-2.0.0}/requirements.txt +0 -0
  29. {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: 1.3.0
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"
@@ -1,5 +1,5 @@
1
1
  from .sklearn_api import ComBat
2
2
 
3
3
  __all__ = ["ComBat"]
4
- __version__ = "1.3.0"
4
+ __version__ = "2.0.0"
5
5
  __author__ = "Ettore Rocchi"
@@ -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("covbat_cov_thresh must be in (0, 1] when float.")
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("covbat_cov_thresh must be >= 1 when int.")
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("covbat_cov_thresh must be float or int.")
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
- raise ValueError(f"`{name}` index mismatch with `X`.")
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
- raise ValueError(f"`{name}` index mismatch with `X`.")
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("method must be 'johnson', 'fortin', or 'chen'.")
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 present in the data batches."
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(f"Batch '{lvl}' has <2 samples.")
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, delta_hat_arr, n_per_batch, parametric=self.parametric
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, delta_hat_arr, n_per_batch, parametric=self.parametric
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 present in batches."
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
- design = pd.concat(parts, axis=1).values
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 = batch.value_counts().sort_index().astype(int).values
257
- self._n_per_batch = dict(zip(self._batch_levels, n_per_batch, strict=True))
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).values
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, delta_hat, n_per_batch, parametric=self.parametric
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, delta_hat, n_per_batch, parametric=self.parametric
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, delta_hat_arr, n_per_batch, parametric=self.parametric
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, delta_hat_arr, n_per_batch, parametric=self.parametric
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
- gamma_bar = gamma_hat.mean(axis=0)
378
- t2 = gamma_hat.var(axis=0, ddof=1)
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
- g, d = gamma_hat[i], delta_hat[i]
394
- gamma_post_var = 1.0 / (n_i / d + 1.0 / t2)
395
- gamma_star[i] = gamma_post_var * (n_i * g / d + gamma_bar / t2)
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
- a_post = a_prior + n_i / 2.0
398
- b_post = b_prior + 0.5 * n_i * d
399
- delta_star[i] = b_post / (a_post - 1)
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
- B, _p = gamma_hat.shape
404
- n_vec = (
405
- np.array(list(n_per_batch.values()))
406
- if isinstance(n_per_batch, dict)
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) -> FloatArray:
430
- m, s2 = delta.mean(), delta.var()
431
- s2 = max(s2, self.eps)
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 _ in range(max_iter):
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
- if np.max(np.abs(g_new - g_prev) / (np.abs(g_prev) + self.eps)) < tol and (
451
- self.mean_only
452
- or np.max(np.abs(d_new - d_prev) / (np.abs(d_prev) + self.eps)) < tol
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, delta_hat, n_per_batch, parametric=parametric
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(f"Unknown method: {method}.")
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)[self._batch_levels]
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
- parts = [batch_dummies]
708
+ nonbatch_parts: list[pd.DataFrame] = []
563
709
  if disc is not None:
564
- parts.append(pd.get_dummies(disc.astype("category"), drop_first=True).astype(float))
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
- parts.append(cont.astype(float))
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(parts, axis=1).values
722
+ design = pd.concat([batch_dummies, nonbatch_df], axis=1).values
569
723
 
570
724
  X_np = X.values
571
- stand_mu = self._grand_mean.values + design[:, self._n_batch :] @ self._beta_hat_nonbatch
572
- Xs = (X_np - stand_mu) / np.sqrt(self._pooled_var.values)
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(self._pooled_var.values) + stand_mu
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(