combatlearn 1.2.0__tar.gz → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combatlearn
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Batch-effect harmonization for machine learning frameworks.
5
5
  Author-email: Ettore Rocchi <ettoreroc@gmail.com>
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Dist: pandas>=1.3
16
16
  Requires-Dist: numpy>=1.21
17
17
  Requires-Dist: scikit-learn>=1.2
18
18
  Requires-Dist: matplotlib>=3.4
19
+ Requires-Dist: seaborn>=0.12
19
20
  Requires-Dist: plotly>=5.0
20
21
  Requires-Dist: nbformat>=4.2
21
22
  Requires-Dist: umap-learn>=0.5
@@ -26,7 +27,7 @@ Requires-Dist: pre-commit>=3.0; extra == "dev"
26
27
  Provides-Extra: docs
27
28
  Requires-Dist: sphinx>=7.0.0; extra == "docs"
28
29
  Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
29
- Requires-Dist: myst-parser>=3.0.0; extra == "docs"
30
+ Requires-Dist: myst-nb>=1.1.0; extra == "docs"
30
31
  Requires-Dist: sphinx-copybutton>=0.5.0; extra == "docs"
31
32
  Requires-Dist: linkify-it-py>=2.0.0; extra == "docs"
32
33
  Dynamic: license-file
@@ -76,7 +77,7 @@ from combatlearn import ComBat
76
77
  df = pd.read_csv("data.csv", index_col=0)
77
78
  X, y = df.drop(columns="y"), df["y"]
78
79
 
79
- batch = pd.read_csv("batch.csv", index_col=0, squeeze=True)
80
+ batch = pd.read_csv("batch.csv", index_col=0).squeeze("columns")
80
81
  diag = pd.read_csv("diagnosis.csv", index_col=0) # categorical
81
82
  age = pd.read_csv("age.csv", index_col=0) # continuous
82
83
 
@@ -43,7 +43,7 @@ from combatlearn import ComBat
43
43
  df = pd.read_csv("data.csv", index_col=0)
44
44
  X, y = df.drop(columns="y"), df["y"]
45
45
 
46
- batch = pd.read_csv("batch.csv", index_col=0, squeeze=True)
46
+ batch = pd.read_csv("batch.csv", index_col=0).squeeze("columns")
47
47
  diag = pd.read_csv("diagnosis.csv", index_col=0) # categorical
48
48
  age = pd.read_csv("age.csv", index_col=0) # continuous
49
49
 
@@ -1,5 +1,5 @@
1
1
  from .sklearn_api import ComBat
2
2
 
3
3
  __all__ = ["ComBat"]
4
- __version__ = "1.2.0"
4
+ __version__ = "1.3.0"
5
5
  __author__ = "Ettore Rocchi"
@@ -117,7 +117,26 @@ class ComBatModel:
117
117
  discrete_covariates: ArrayLike | None = None,
118
118
  continuous_covariates: ArrayLike | None = None,
119
119
  ) -> ComBatModel:
120
- """Fit the ComBat model."""
120
+ """Fit the ComBat model.
121
+
122
+ Parameters
123
+ ----------
124
+ X : array-like of shape (n_samples, n_features)
125
+ Input data to fit.
126
+ y : None
127
+ Ignored. Present for API compatibility.
128
+ batch : array-like of shape (n_samples,)
129
+ Batch labels for each sample.
130
+ discrete_covariates : array-like or None, default=None
131
+ Categorical covariates to protect (Fortin/Chen only).
132
+ continuous_covariates : array-like or None, default=None
133
+ Continuous covariates to protect (Fortin/Chen only).
134
+
135
+ Returns
136
+ -------
137
+ self : ComBatModel
138
+ Fitted model.
139
+ """
121
140
  method = self.method.lower()
122
141
  if method not in {"johnson", "fortin", "chen"}:
123
142
  raise ValueError("method must be 'johnson', 'fortin', or 'chen'.")
@@ -459,7 +478,29 @@ class ComBatModel:
459
478
  discrete_covariates: ArrayLike | None = None,
460
479
  continuous_covariates: ArrayLike | None = None,
461
480
  ) -> pd.DataFrame:
462
- """Transform the data using fitted ComBat parameters."""
481
+ """Transform the data using fitted ComBat parameters.
482
+
483
+ Parameters
484
+ ----------
485
+ X : array-like of shape (n_samples, n_features)
486
+ Input data to transform.
487
+ batch : array-like of shape (n_samples,)
488
+ Batch labels for each sample.
489
+ discrete_covariates : array-like or None, default=None
490
+ Categorical covariates (Fortin/Chen only).
491
+ continuous_covariates : array-like or None, default=None
492
+ Continuous covariates (Fortin/Chen only).
493
+
494
+ Returns
495
+ -------
496
+ X_adjusted : pd.DataFrame
497
+ Batch-corrected data.
498
+
499
+ Raises
500
+ ------
501
+ ValueError
502
+ If the model is not fitted or if unseen batch levels are present.
503
+ """
463
504
  if not hasattr(self, "_gamma_star"):
464
505
  raise ValueError(
465
506
  "This ComBatModel instance is not fitted yet. Call 'fit' before 'transform'."
@@ -111,6 +111,7 @@ def _kbet_score(
111
111
  batch_labels: np.ndarray,
112
112
  k0: int,
113
113
  alpha: float = 0.05,
114
+ nn_algorithm: str = "auto",
114
115
  ) -> tuple[float, float]:
115
116
  """
116
117
  Compute kBET (k-nearest neighbor Batch Effect Test) acceptance rate.
@@ -130,6 +131,9 @@ def _kbet_score(
130
131
  Neighborhood size.
131
132
  alpha : float
132
133
  Significance level for chi-squared test.
134
+ nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
135
+ Algorithm used for nearest neighbor computation. Passed to
136
+ ``sklearn.neighbors.NearestNeighbors``.
133
137
 
134
138
  Returns
135
139
  -------
@@ -148,7 +152,7 @@ def _kbet_score(
148
152
  global_freq = batch_counts / n_samples
149
153
  k0 = min(k0, n_samples - 1)
150
154
 
151
- nn = NearestNeighbors(n_neighbors=k0 + 1, algorithm="auto")
155
+ nn = NearestNeighbors(n_neighbors=k0 + 1, algorithm=nn_algorithm)
152
156
  nn.fit(X)
153
157
  _, indices = nn.kneighbors(X)
154
158
 
@@ -236,6 +240,7 @@ def _lisi_score(
236
240
  X: np.ndarray,
237
241
  batch_labels: np.ndarray,
238
242
  perplexity: int = 30,
243
+ nn_algorithm: str = "auto",
239
244
  ) -> float:
240
245
  """
241
246
  Compute mean Local Inverse Simpson's Index (LISI).
@@ -253,6 +258,9 @@ def _lisi_score(
253
258
  Batch labels for each sample.
254
259
  perplexity : int
255
260
  Perplexity for Gaussian kernel.
261
+ nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
262
+ Algorithm used for nearest neighbor computation. Passed to
263
+ ``sklearn.neighbors.NearestNeighbors``.
256
264
 
257
265
  Returns
258
266
  -------
@@ -269,7 +277,7 @@ def _lisi_score(
269
277
 
270
278
  k = min(3 * perplexity, n_samples - 1)
271
279
 
272
- nn = NearestNeighbors(n_neighbors=k + 1, algorithm="auto")
280
+ nn = NearestNeighbors(n_neighbors=k + 1, algorithm=nn_algorithm)
273
281
  nn.fit(X)
274
282
  distances, indices = nn.kneighbors(X)
275
283
 
@@ -354,6 +362,7 @@ def _knn_preservation(
354
362
  X_after: np.ndarray,
355
363
  k_values: list[int],
356
364
  n_jobs: int = 1,
365
+ nn_algorithm: str = "auto",
357
366
  ) -> dict[int, float]:
358
367
  """
359
368
  Compute fraction of k-nearest neighbors preserved after correction.
@@ -371,6 +380,9 @@ def _knn_preservation(
371
380
  Values of k for k-NN.
372
381
  n_jobs : int
373
382
  Number of parallel jobs.
383
+ nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
384
+ Algorithm used for nearest neighbor computation. Passed to
385
+ ``sklearn.neighbors.NearestNeighbors``.
374
386
 
375
387
  Returns
376
388
  -------
@@ -381,11 +393,11 @@ def _knn_preservation(
381
393
  max_k = max(k_values)
382
394
  max_k = min(max_k, X_before.shape[0] - 1)
383
395
 
384
- nn_before = NearestNeighbors(n_neighbors=max_k + 1, algorithm="auto", n_jobs=n_jobs)
396
+ nn_before = NearestNeighbors(n_neighbors=max_k + 1, algorithm=nn_algorithm, n_jobs=n_jobs)
385
397
  nn_before.fit(X_before)
386
398
  _, indices_before = nn_before.kneighbors(X_before)
387
399
 
388
- nn_after = NearestNeighbors(n_neighbors=max_k + 1, algorithm="auto", n_jobs=n_jobs)
400
+ nn_after = NearestNeighbors(n_neighbors=max_k + 1, algorithm=nn_algorithm, n_jobs=n_jobs)
389
401
  nn_after.fit(X_after)
390
402
  _, indices_after = nn_after.kneighbors(X_after)
391
403
 
@@ -557,6 +569,7 @@ class ComBatMetricsMixin:
557
569
  kbet_k0: int | None = None,
558
570
  lisi_perplexity: int = 30,
559
571
  n_jobs: int = 1,
572
+ nn_algorithm: str = "auto",
560
573
  ) -> dict[str, Any]:
561
574
  """
562
575
  Compute batch effect metrics before and after ComBat correction.
@@ -579,6 +592,9 @@ class ComBatMetricsMixin:
579
592
  Perplexity for LISI computation.
580
593
  n_jobs : int, default=1
581
594
  Number of parallel jobs for neighbor computations.
595
+ nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
596
+ Algorithm used for nearest neighbor computation. Passed to
597
+ ``sklearn.neighbors.NearestNeighbors``.
582
598
 
583
599
  Returns
584
600
  -------
@@ -596,7 +612,11 @@ class ComBatMetricsMixin:
596
612
  ValueError
597
613
  If the model is not fitted or if pca_components is invalid.
598
614
  """
599
- if not hasattr(self._model, "_gamma_star"):
615
+ _valid_nn = {"auto", "ball_tree", "kd_tree", "brute"}
616
+ if nn_algorithm not in _valid_nn:
617
+ raise ValueError(f"nn_algorithm must be one of {_valid_nn}, got '{nn_algorithm}'")
618
+
619
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
600
620
  raise ValueError(
601
621
  "This ComBat instance is not fitted yet. Call 'fit' before 'compute_batch_metrics'."
602
622
  )
@@ -646,16 +666,22 @@ class ComBatMetricsMixin:
646
666
  db_before = _davies_bouldin_batch(X_before_pca, batch_labels)
647
667
  db_after = _davies_bouldin_batch(X_after_pca, batch_labels)
648
668
 
649
- kbet_before, _ = _kbet_score(X_before_pca, batch_labels, kbet_k0)
650
- kbet_after, _ = _kbet_score(X_after_pca, batch_labels, kbet_k0)
669
+ kbet_before, _ = _kbet_score(X_before_pca, batch_labels, kbet_k0, nn_algorithm=nn_algorithm)
670
+ kbet_after, _ = _kbet_score(X_after_pca, batch_labels, kbet_k0, nn_algorithm=nn_algorithm)
651
671
 
652
- lisi_before = _lisi_score(X_before_pca, batch_labels, lisi_perplexity)
653
- lisi_after = _lisi_score(X_after_pca, batch_labels, lisi_perplexity)
672
+ lisi_before = _lisi_score(
673
+ X_before_pca, batch_labels, lisi_perplexity, nn_algorithm=nn_algorithm
674
+ )
675
+ lisi_after = _lisi_score(
676
+ X_after_pca, batch_labels, lisi_perplexity, nn_algorithm=nn_algorithm
677
+ )
654
678
 
655
679
  var_ratio_before = _variance_ratio(X_before_pca, batch_labels)
656
680
  var_ratio_after = _variance_ratio(X_after_pca, batch_labels)
657
681
 
658
- knn_results = _knn_preservation(X_before_pca, X_after_pca, k_neighbors, n_jobs)
682
+ knn_results = _knn_preservation(
683
+ X_before_pca, X_after_pca, k_neighbors, n_jobs, nn_algorithm=nn_algorithm
684
+ )
659
685
  dist_corr = _pairwise_distance_correlation(X_before_pca, X_after_pca)
660
686
 
661
687
  centroid_before = _mean_centroid_distance(X_before_pca, batch_labels)
@@ -743,7 +769,7 @@ class ComBatMetricsMixin:
743
769
  ValueError
744
770
  If the model is not fitted or if mode is invalid.
745
771
  """
746
- if not hasattr(self._model, "_gamma_star"):
772
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
747
773
  raise ValueError(
748
774
  "This ComBat instance is not fitted yet. "
749
775
  "Call 'fit' before 'feature_batch_importance'."
@@ -0,0 +1,277 @@
1
+ """Scikit-learn compatible ComBat wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.base import BaseEstimator, TransformerMixin
8
+ from sklearn.utils.validation import check_array, check_is_fitted
9
+
10
+ from .core import ArrayLike, ComBatModel
11
+ from .metrics import ComBatMetricsMixin
12
+ from .visualization import ComBatVisualizationMixin
13
+
14
+
15
+ class ComBat(ComBatMetricsMixin, ComBatVisualizationMixin, BaseEstimator, TransformerMixin):
16
+ """Pipeline-friendly wrapper around `ComBatModel`.
17
+
18
+ Stores batch (and optional covariates) passed at construction and
19
+ appropriately uses them for separate `fit` and `transform`.
20
+
21
+ Parameters
22
+ ----------
23
+ batch : array-like of shape (n_samples,)
24
+ Batch labels for each sample.
25
+ discrete_covariates : array-like, optional
26
+ Categorical covariates to protect (Fortin/Chen only).
27
+ continuous_covariates : array-like, optional
28
+ Continuous covariates to protect (Fortin/Chen only).
29
+ method : {'johnson', 'fortin', 'chen'}, default='johnson'
30
+ ComBat variant to use.
31
+ parametric : bool, default=True
32
+ Use parametric empirical Bayes.
33
+ mean_only : bool, default=False
34
+ Adjust only the mean (ignore variance).
35
+ reference_batch : str, optional
36
+ Batch level to leave unchanged.
37
+ eps : float, default=1e-8
38
+ Numerical jitter for stability.
39
+ covbat_cov_thresh : float or int, default=0.9
40
+ CovBat variance threshold for PCs.
41
+ compute_metrics : bool, default=False
42
+ If True, ``fit_transform`` caches batch metrics in ``metrics_``.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ batch: ArrayLike,
48
+ *,
49
+ discrete_covariates: ArrayLike | None = None,
50
+ continuous_covariates: ArrayLike | None = None,
51
+ method: str = "johnson",
52
+ parametric: bool = True,
53
+ mean_only: bool = False,
54
+ reference_batch: str | None = None,
55
+ eps: float = 1e-8,
56
+ covbat_cov_thresh: float | int = 0.9,
57
+ compute_metrics: bool = False,
58
+ ) -> None:
59
+ self.batch = batch
60
+ self.discrete_covariates = discrete_covariates
61
+ self.continuous_covariates = continuous_covariates
62
+ self.method = method
63
+ self.parametric = parametric
64
+ self.mean_only = mean_only
65
+ self.reference_batch = reference_batch
66
+ self.eps = eps
67
+ self.covbat_cov_thresh = covbat_cov_thresh
68
+ self.compute_metrics = compute_metrics
69
+
70
+ def _validate_inputs(self, X: ArrayLike, *, fitting: bool = False) -> None:
71
+ """Validate X and, during fitting, batch/covariates for NaN/Inf."""
72
+ check_array(X, ensure_all_finite=True, dtype="numeric")
73
+
74
+ if fitting:
75
+ batch_ser = (
76
+ pd.Series(self.batch) if not isinstance(self.batch, pd.Series) else self.batch
77
+ )
78
+ if batch_ser.isna().any():
79
+ raise ValueError("batch contains NaN values.")
80
+
81
+ if self.discrete_covariates is not None:
82
+ disc_df = (
83
+ pd.DataFrame(self.discrete_covariates)
84
+ if not isinstance(self.discrete_covariates, (pd.Series, pd.DataFrame))
85
+ else self.discrete_covariates
86
+ )
87
+ if (
88
+ disc_df.isna().any().any()
89
+ if isinstance(disc_df, pd.DataFrame)
90
+ else disc_df.isna().any()
91
+ ):
92
+ raise ValueError("discrete_covariates contains NaN values.")
93
+
94
+ if self.continuous_covariates is not None:
95
+ cont_arr = self.continuous_covariates
96
+ if isinstance(cont_arr, (pd.Series, pd.DataFrame)):
97
+ cont_arr = cont_arr.values
98
+ check_array(
99
+ np.atleast_2d(cont_arr) if np.asarray(cont_arr).ndim == 1 else cont_arr,
100
+ ensure_all_finite=True,
101
+ dtype="numeric",
102
+ )
103
+
104
+ def fit(self, X: ArrayLike, y: ArrayLike | None = None) -> ComBat:
105
+ """Fit the ComBat model.
106
+
107
+ Parameters
108
+ ----------
109
+ X : array-like of shape (n_samples, n_features)
110
+ Input data to fit.
111
+ y : None
112
+ Ignored. Present for API compatibility.
113
+
114
+ Returns
115
+ -------
116
+ self : ComBat
117
+ Fitted estimator.
118
+ """
119
+ self._validate_inputs(X, fitting=True)
120
+ idx = X.index if isinstance(X, pd.DataFrame) else pd.RangeIndex(len(X))
121
+
122
+ if isinstance(X, pd.DataFrame):
123
+ self.feature_names_in_ = np.asarray(X.columns, dtype=object)
124
+ else:
125
+ self.feature_names_in_ = np.asarray(
126
+ [f"x{i}" for i in range(np.asarray(X).shape[1])], dtype=object
127
+ )
128
+
129
+ self._model = ComBatModel(
130
+ method=self.method,
131
+ parametric=self.parametric,
132
+ mean_only=self.mean_only,
133
+ reference_batch=self.reference_batch,
134
+ eps=self.eps,
135
+ covbat_cov_thresh=self.covbat_cov_thresh,
136
+ )
137
+
138
+ batch_vec = self._subset(self.batch, idx)
139
+ disc = self._subset(self.discrete_covariates, idx)
140
+ cont = self._subset(self.continuous_covariates, idx)
141
+ self._model.fit(
142
+ X,
143
+ batch=batch_vec,
144
+ discrete_covariates=disc,
145
+ continuous_covariates=cont,
146
+ )
147
+ self._fitted_batch = batch_vec
148
+ return self
149
+
150
+ def transform(self, X: ArrayLike) -> pd.DataFrame:
151
+ """Transform the data using fitted ComBat parameters.
152
+
153
+ Parameters
154
+ ----------
155
+ X : array-like of shape (n_samples, n_features)
156
+ Input data to transform.
157
+
158
+ Returns
159
+ -------
160
+ X_transformed : pd.DataFrame
161
+ Batch-corrected data.
162
+ """
163
+ self._validate_inputs(X)
164
+ idx = X.index if isinstance(X, pd.DataFrame) else pd.RangeIndex(len(X))
165
+ batch_vec = self._subset(self.batch, idx)
166
+ disc = self._subset(self.discrete_covariates, idx)
167
+ cont = self._subset(self.continuous_covariates, idx)
168
+ return self._model.transform(
169
+ X,
170
+ batch=batch_vec,
171
+ discrete_covariates=disc,
172
+ continuous_covariates=cont,
173
+ )
174
+
175
+ @staticmethod
176
+ def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
177
+ """Subset array-like object by index."""
178
+ if obj is None:
179
+ return None
180
+ if isinstance(obj, (pd.Series, pd.DataFrame)):
181
+ return obj.loc[idx]
182
+ else:
183
+ if isinstance(obj, np.ndarray) and obj.ndim == 1:
184
+ return pd.Series(obj, index=idx)
185
+ else:
186
+ return pd.DataFrame(obj, index=idx)
187
+
188
+ def fit_transform(self, X: ArrayLike, y: ArrayLike | None = None) -> pd.DataFrame:
189
+ """
190
+ Fit and transform the data, optionally computing metrics.
191
+
192
+ If ``compute_metrics=True`` was set at construction, batch effect
193
+ metrics are computed and cached in the ``metrics_`` property.
194
+
195
+ Parameters
196
+ ----------
197
+ X : array-like of shape (n_samples, n_features)
198
+ Input data to fit and transform.
199
+ y : None
200
+ Ignored. Present for API compatibility.
201
+
202
+ Returns
203
+ -------
204
+ X_transformed : pd.DataFrame
205
+ Batch-corrected data.
206
+ """
207
+ self.fit(X, y)
208
+ X_transformed = self.transform(X)
209
+
210
+ if self.compute_metrics:
211
+ self._metrics_cache = self.compute_batch_metrics(X)
212
+
213
+ return X_transformed
214
+
215
+ def get_feature_names_out(self, input_features: ArrayLike | None = None) -> np.ndarray:
216
+ """Get output feature names for transform.
217
+
218
+ Parameters
219
+ ----------
220
+ input_features : array-like of str or None, default=None
221
+ Ignored. Present for API compatibility.
222
+
223
+ Returns
224
+ -------
225
+ feature_names_out : ndarray of str objects
226
+ Feature names.
227
+
228
+ Raises
229
+ ------
230
+ sklearn.exceptions.NotFittedError
231
+ If the estimator is not fitted.
232
+ """
233
+ check_is_fitted(self, "feature_names_in_")
234
+ return self.feature_names_in_
235
+
236
+ def summary(self) -> str:
237
+ """Return a human-readable diagnostic report after fitting.
238
+
239
+ Returns
240
+ -------
241
+ str
242
+ Multi-line summary string.
243
+
244
+ Raises
245
+ ------
246
+ ValueError
247
+ If the model is not fitted.
248
+ """
249
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
250
+ raise ValueError("This ComBat instance is not fitted yet. Call 'fit' before 'summary'.")
251
+
252
+ lines = []
253
+ lines.append("ComBat Summary")
254
+ lines.append("=" * 40)
255
+ lines.append(f"Method: {self.method}")
256
+ lines.append(f"Parametric: {self.parametric}")
257
+ lines.append(f"Mean only: {self.mean_only}")
258
+ lines.append(f"Reference batch: {self.reference_batch or 'None'}")
259
+
260
+ batch_levels = self._model._batch_levels
261
+ n_per_batch = self._model._n_per_batch
262
+ lines.append(f"Number of batches: {len(batch_levels)}")
263
+ lines.append("Samples per batch:")
264
+ for lvl in batch_levels:
265
+ lines.append(f" {lvl}: {n_per_batch[str(lvl)]}")
266
+
267
+ n_features = len(self._model._grand_mean)
268
+ lines.append(f"Number of features: {n_features}")
269
+
270
+ lines.append("")
271
+ lines.append("Top 5 features by batch effect (combined):")
272
+ importance = self.feature_batch_importance()
273
+ top5 = importance.head(5)
274
+ for feat, row in top5.iterrows():
275
+ lines.append(f" {feat}: {row['combined']:.4f}")
276
+
277
+ return "\n".join(lines)
@@ -10,6 +10,7 @@ import matplotlib.pyplot as plt
10
10
  import numpy as np
11
11
  import pandas as pd
12
12
  import plotly.graph_objects as go
13
+ import seaborn as sns
13
14
  import umap
14
15
  from plotly.subplots import make_subplots
15
16
  from sklearn.decomposition import PCA
@@ -59,11 +60,30 @@ class ComBatVisualizationMixin:
59
60
  - `'static'`: matplotlib plots (can be saved as images)
60
61
  - `'interactive'`: plotly plots (explorable, requires plotly)
61
62
 
63
+ figsize : tuple of int, default=(12, 5)
64
+ Figure size in inches (width, height). Only used for static plots.
65
+
66
+ alpha : float, default=0.7
67
+ Marker transparency. Only used for static plots.
68
+
69
+ point_size : int, default=50
70
+ Marker size. Only used for static plots.
71
+
72
+ cmap : str, default='Set1'
73
+ Matplotlib colormap name for batch colors.
74
+
75
+ title : str or None, default=None
76
+ Custom figure title. If None, a default title is generated.
77
+
78
+ show_legend : bool, default=True
79
+ Whether to display the batch legend.
80
+
62
81
  return_embeddings : bool, default=False
63
82
  If `True`, return embeddings along with the plot.
64
83
 
65
84
  **reduction_kwargs : dict
66
- Additional parameters for reduction methods.
85
+ Additional keyword arguments passed to the reduction method
86
+ (e.g., ``perplexity`` for t-SNE, ``n_neighbors`` for UMAP).
67
87
 
68
88
  Returns
69
89
  -------
@@ -75,7 +95,7 @@ class ComBatVisualizationMixin:
75
95
  - `'original'`: embedding of original data
76
96
  - `'transformed'`: embedding of ComBat-transformed data
77
97
  """
78
- if not hasattr(self._model, "_gamma_star"):
98
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
79
99
  raise ValueError(
80
100
  "This ComBat instance is not fitted yet. Call 'fit' before 'plot_transformation'."
81
101
  )
@@ -439,7 +459,7 @@ class ComBatVisualizationMixin:
439
459
  ValueError
440
460
  If the model is not fitted, or if kind/mode is invalid.
441
461
  """
442
- if not hasattr(self._model, "_gamma_star"):
462
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
443
463
  raise ValueError(
444
464
  "This ComBat instance is not fitted yet. "
445
465
  "Call 'fit' before 'plot_feature_importance'."
@@ -531,3 +551,93 @@ class ComBatVisualizationMixin:
531
551
  print(f"Top {top_n} features explain {cumulative_pct:.1f}% of total {effect_label}")
532
552
 
533
553
  return fig
554
+
555
+ def plot_batch_effect_heatmap(
556
+ self,
557
+ top_n: int = 50,
558
+ figsize: tuple[int, int] = (12, 8),
559
+ ) -> Any:
560
+ """Plot a heatmap of batch effect parameters across features and batches.
561
+
562
+ Displays the estimated batch-specific location shifts (gamma) and,
563
+ unless ``mean_only=True``, log-scale shifts (log delta) for the
564
+ ``top_n`` most affected features.
565
+
566
+ Parameters
567
+ ----------
568
+ top_n : int, default=50
569
+ Number of top features (by combined batch effect) to display.
570
+ figsize : tuple of int, default=(12, 8)
571
+ Figure size in inches.
572
+
573
+ Returns
574
+ -------
575
+ matplotlib.figure.Figure
576
+ Figure containing the heatmap(s).
577
+
578
+ Raises
579
+ ------
580
+ ValueError
581
+ If the model is not fitted.
582
+ ImportError
583
+ If seaborn is not installed.
584
+ """
585
+ if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
586
+ raise ValueError(
587
+ "This ComBat instance is not fitted yet. "
588
+ "Call 'fit' before 'plot_batch_effect_heatmap'."
589
+ )
590
+
591
+ feature_names = self._model._grand_mean.index
592
+ batch_levels = self._model._batch_levels
593
+ gamma_star = self._model._gamma_star
594
+ delta_star = self._model._delta_star
595
+
596
+ importance = self.feature_batch_importance()
597
+ top_features = importance.head(top_n).index
598
+ feat_idx = [feature_names.get_loc(f) for f in top_features]
599
+
600
+ gamma_df = pd.DataFrame(
601
+ gamma_star[:, feat_idx],
602
+ index=[str(b) for b in batch_levels],
603
+ columns=top_features,
604
+ )
605
+
606
+ n_plots = 1 if self.mean_only else 2
607
+ fig, axes = plt.subplots(1, n_plots, figsize=figsize)
608
+ if n_plots == 1:
609
+ axes = [axes]
610
+
611
+ sns.heatmap(
612
+ gamma_df,
613
+ cmap="RdBu_r",
614
+ center=0,
615
+ ax=axes[0],
616
+ xticklabels=True,
617
+ yticklabels=True,
618
+ )
619
+ axes[0].set_title("Location shifts (gamma)")
620
+ axes[0].set_xlabel("Feature")
621
+ axes[0].set_ylabel("Batch")
622
+
623
+ if not self.mean_only:
624
+ log_delta_df = pd.DataFrame(
625
+ np.log(delta_star[:, feat_idx]),
626
+ index=[str(b) for b in batch_levels],
627
+ columns=top_features,
628
+ )
629
+ sns.heatmap(
630
+ log_delta_df,
631
+ cmap="RdBu_r",
632
+ center=0,
633
+ ax=axes[1],
634
+ xticklabels=True,
635
+ yticklabels=True,
636
+ )
637
+ axes[1].set_title("Scale shifts (log delta)")
638
+ axes[1].set_xlabel("Feature")
639
+ axes[1].set_ylabel("Batch")
640
+
641
+ fig.suptitle(f"Batch Effect Heatmap (top {top_n} features)", fontsize=14, fontweight="bold")
642
+ plt.tight_layout()
643
+ return fig
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combatlearn
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Batch-effect harmonization for machine learning frameworks.
5
5
  Author-email: Ettore Rocchi <ettoreroc@gmail.com>
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Dist: pandas>=1.3
16
16
  Requires-Dist: numpy>=1.21
17
17
  Requires-Dist: scikit-learn>=1.2
18
18
  Requires-Dist: matplotlib>=3.4
19
+ Requires-Dist: seaborn>=0.12
19
20
  Requires-Dist: plotly>=5.0
20
21
  Requires-Dist: nbformat>=4.2
21
22
  Requires-Dist: umap-learn>=0.5
@@ -26,7 +27,7 @@ Requires-Dist: pre-commit>=3.0; extra == "dev"
26
27
  Provides-Extra: docs
27
28
  Requires-Dist: sphinx>=7.0.0; extra == "docs"
28
29
  Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
29
- Requires-Dist: myst-parser>=3.0.0; extra == "docs"
30
+ Requires-Dist: myst-nb>=1.1.0; extra == "docs"
30
31
  Requires-Dist: sphinx-copybutton>=0.5.0; extra == "docs"
31
32
  Requires-Dist: linkify-it-py>=2.0.0; extra == "docs"
32
33
  Dynamic: license-file
@@ -76,7 +77,7 @@ from combatlearn import ComBat
76
77
  df = pd.read_csv("data.csv", index_col=0)
77
78
  X, y = df.drop(columns="y"), df["y"]
78
79
 
79
- batch = pd.read_csv("batch.csv", index_col=0, squeeze=True)
80
+ batch = pd.read_csv("batch.csv", index_col=0).squeeze("columns")
80
81
  diag = pd.read_csv("diagnosis.csv", index_col=0) # categorical
81
82
  age = pd.read_csv("age.csv", index_col=0) # continuous
82
83
 
@@ -2,6 +2,7 @@ pandas>=1.3
2
2
  numpy>=1.21
3
3
  scikit-learn>=1.2
4
4
  matplotlib>=3.4
5
+ seaborn>=0.12
5
6
  plotly>=5.0
6
7
  nbformat>=4.2
7
8
  umap-learn>=0.5
@@ -14,6 +15,6 @@ pre-commit>=3.0
14
15
  [docs]
15
16
  sphinx>=7.0.0
16
17
  pydata-sphinx-theme>=0.15.0
17
- myst-parser>=3.0.0
18
+ myst-nb>=1.1.0
18
19
  sphinx-copybutton>=0.5.0
19
20
  linkify-it-py>=2.0.0
@@ -1,5 +1,5 @@
1
1
  sphinx>=7.0.0
2
2
  pydata-sphinx-theme>=0.15.0
3
- myst-parser>=3.0.0
3
+ myst-nb>=1.1.0
4
4
  sphinx-copybutton>=0.5.0
5
5
  linkify-it-py>=2.0.0
@@ -44,4 +44,3 @@ ignore = ["E501", "B008"]
44
44
 
45
45
  [tool.ruff.lint.per-file-ignores]
46
46
  "tests/*" = ["S101", "PLR2004"]
47
- "examples/*" = ["F401"]
@@ -2,6 +2,7 @@ pandas>=1.3
2
2
  numpy>=1.21
3
3
  scikit-learn>=1.2
4
4
  matplotlib>=3.4
5
+ seaborn>=0.12
5
6
  plotly>=5.0
6
7
  nbformat>=4.2
7
8
  umap-learn>=0.5
@@ -2,6 +2,7 @@ import numpy as np
2
2
  import pandas as pd
3
3
  import pytest
4
4
  from sklearn.base import clone
5
+ from sklearn.exceptions import NotFittedError
5
6
  from sklearn.pipeline import Pipeline
6
7
  from sklearn.preprocessing import StandardScaler
7
8
  from utils import simulate_covariate_data, simulate_data
@@ -653,3 +654,198 @@ def test_plot_feature_importance_distribution_shows_cumulative(capsys):
653
654
  captured = capsys.readouterr()
654
655
  assert "features explain" in captured.out
655
656
  assert "%" in captured.out
657
+
658
+
659
+ def test_fit_nan_in_X_raises():
660
+ """Fitting with NaN in X must raise ValueError."""
661
+ X, batch = simulate_data()
662
+ X.iloc[0, 0] = np.nan
663
+ with pytest.raises(ValueError):
664
+ ComBat(batch=batch).fit(X)
665
+
666
+
667
+ def test_fit_inf_in_X_raises():
668
+ """Fitting with Inf in X must raise ValueError."""
669
+ X, batch = simulate_data()
670
+ X.iloc[0, 0] = np.inf
671
+ with pytest.raises(ValueError):
672
+ ComBat(batch=batch).fit(X)
673
+
674
+
675
+ def test_transform_nan_in_X_raises():
676
+ """Transforming with NaN in X must raise ValueError."""
677
+ X, batch = simulate_data()
678
+ combat = ComBat(batch=batch).fit(X)
679
+ X_bad = X.copy()
680
+ X_bad.iloc[0, 0] = np.nan
681
+ with pytest.raises(ValueError):
682
+ combat.transform(X_bad)
683
+
684
+
685
+ def test_fit_nan_in_batch_raises():
686
+ """Fitting with NaN in batch must raise ValueError."""
687
+ X, batch = simulate_data()
688
+ batch.iloc[0] = np.nan
689
+ with pytest.raises(ValueError, match="batch contains NaN"):
690
+ ComBat(batch=batch).fit(X)
691
+
692
+
693
+ def test_fit_nan_in_discrete_covariates_raises():
694
+ """Fitting with NaN in discrete covariates must raise ValueError."""
695
+ X, batch, disc, cont = simulate_covariate_data()
696
+ disc.iloc[0, 0] = np.nan
697
+ with pytest.raises(ValueError, match="discrete_covariates contains NaN"):
698
+ ComBat(
699
+ batch=batch, discrete_covariates=disc, continuous_covariates=cont, method="fortin"
700
+ ).fit(X)
701
+
702
+
703
+ def test_fit_nan_in_continuous_covariates_raises():
704
+ """Fitting with NaN in continuous covariates must raise ValueError."""
705
+ X, batch, disc, cont = simulate_covariate_data()
706
+ cont.iloc[0, 0] = np.nan
707
+ with pytest.raises(ValueError):
708
+ ComBat(
709
+ batch=batch, discrete_covariates=disc, continuous_covariates=cont, method="fortin"
710
+ ).fit(X)
711
+
712
+
713
+ def test_metrics_nn_algorithm_ball_tree():
714
+ """compute_batch_metrics with nn_algorithm='ball_tree' must work."""
715
+ X, batch = simulate_data(n_samples=100, n_features=20)
716
+ combat = ComBat(batch=batch).fit(X)
717
+ metrics = combat.compute_batch_metrics(X, k_neighbors=[5], nn_algorithm="ball_tree")
718
+ assert "batch_effect" in metrics
719
+
720
+
721
+ def test_metrics_nn_algorithm_kd_tree():
722
+ """compute_batch_metrics with nn_algorithm='kd_tree' must work."""
723
+ X, batch = simulate_data(n_samples=100, n_features=20)
724
+ combat = ComBat(batch=batch).fit(X)
725
+ metrics = combat.compute_batch_metrics(X, k_neighbors=[5], nn_algorithm="kd_tree")
726
+ assert "batch_effect" in metrics
727
+
728
+
729
+ def test_metrics_nn_algorithm_brute():
730
+ """compute_batch_metrics with nn_algorithm='brute' must work."""
731
+ X, batch = simulate_data(n_samples=100, n_features=20)
732
+ combat = ComBat(batch=batch).fit(X)
733
+ metrics = combat.compute_batch_metrics(X, k_neighbors=[5], nn_algorithm="brute")
734
+ assert "batch_effect" in metrics
735
+
736
+
737
+ def test_metrics_nn_algorithm_invalid_raises():
738
+ """compute_batch_metrics with invalid nn_algorithm must raise ValueError."""
739
+ X, batch = simulate_data(n_samples=100, n_features=20)
740
+ combat = ComBat(batch=batch).fit(X)
741
+ with pytest.raises(ValueError, match="nn_algorithm"):
742
+ combat.compute_batch_metrics(X, nn_algorithm="invalid")
743
+
744
+
745
+ def test_get_feature_names_out_dataframe():
746
+ """get_feature_names_out returns column names from DataFrame input."""
747
+ X, batch = simulate_data(n_samples=100, n_features=10)
748
+ X.columns = [f"feat_{i}" for i in range(10)]
749
+ combat = ComBat(batch=batch).fit(X)
750
+ names = combat.get_feature_names_out()
751
+ assert list(names) == [f"feat_{i}" for i in range(10)]
752
+
753
+
754
+ def test_get_feature_names_out_ndarray():
755
+ """get_feature_names_out returns default names for numpy input."""
756
+ X, batch = simulate_data(n_samples=100, n_features=10)
757
+ combat = ComBat(batch=batch).fit(X.values)
758
+ names = combat.get_feature_names_out()
759
+ assert list(names) == [f"x{i}" for i in range(10)]
760
+
761
+
762
+ def test_get_feature_names_out_not_fitted_raises():
763
+ """get_feature_names_out before fit raises NotFittedError."""
764
+ _X, batch = simulate_data()
765
+ combat = ComBat(batch=batch)
766
+ with pytest.raises(NotFittedError):
767
+ combat.get_feature_names_out()
768
+
769
+
770
+ def test_set_output_pandas_in_pipeline():
771
+ """Pipeline with set_output(transform='pandas') works with ComBat."""
772
+ X, batch = simulate_data(n_samples=100, n_features=10)
773
+ X.columns = [f"feat_{i}" for i in range(10)]
774
+ pipe = Pipeline([("combat", ComBat(batch=batch))])
775
+ pipe.set_output(transform="pandas")
776
+ result = pipe.fit_transform(X)
777
+ assert isinstance(result, pd.DataFrame)
778
+ assert list(result.columns) == [f"feat_{i}" for i in range(10)]
779
+
780
+
781
+ def test_summary_contains_method():
782
+ """summary() output contains the method name."""
783
+ X, batch = simulate_data()
784
+ combat = ComBat(batch=batch, method="johnson").fit(X)
785
+ s = combat.summary()
786
+ assert "johnson" in s
787
+
788
+
789
+ def test_summary_contains_batch_info():
790
+ """summary() output contains batch count and sample counts."""
791
+ X, batch = simulate_data()
792
+ combat = ComBat(batch=batch).fit(X)
793
+ s = combat.summary()
794
+ assert "Number of batches: 3" in s
795
+ assert "A:" in s
796
+ assert "B:" in s
797
+ assert "C:" in s
798
+
799
+
800
+ def test_summary_not_fitted_raises():
801
+ """summary() before fit raises ValueError."""
802
+ _X, batch = simulate_data()
803
+ combat = ComBat(batch=batch)
804
+ with pytest.raises(ValueError, match="not fitted"):
805
+ combat.summary()
806
+
807
+
808
+ def test_heatmap_returns_figure():
809
+ """plot_batch_effect_heatmap returns a matplotlib Figure."""
810
+ import matplotlib.figure
811
+
812
+ X, batch = simulate_data(n_samples=100, n_features=25)
813
+ combat = ComBat(batch=batch).fit(X)
814
+ fig = combat.plot_batch_effect_heatmap(top_n=10)
815
+ assert isinstance(fig, matplotlib.figure.Figure)
816
+
817
+
818
+ def test_heatmap_top_n():
819
+ """plot_batch_effect_heatmap respects top_n parameter."""
820
+ X, batch = simulate_data(n_samples=100, n_features=25)
821
+ combat = ComBat(batch=batch).fit(X)
822
+ fig = combat.plot_batch_effect_heatmap(top_n=10)
823
+ # With mean_only=False, 2 subplots
824
+ assert len(fig.axes) >= 2
825
+
826
+
827
+ def test_heatmap_mean_only():
828
+ """plot_batch_effect_heatmap with mean_only=True shows only 1 heatmap."""
829
+ X, batch = simulate_data(n_samples=100, n_features=25)
830
+ combat = ComBat(batch=batch, mean_only=True).fit(X)
831
+ fig = combat.plot_batch_effect_heatmap(top_n=10)
832
+ # mean_only → 1 subplot (gamma only), but colorbar adds an extra axis
833
+ # so check the first axis title
834
+ assert "gamma" in fig.axes[0].get_title().lower()
835
+
836
+
837
+ def test_heatmap_not_fitted_raises():
838
+ """plot_batch_effect_heatmap before fit raises ValueError."""
839
+ _X, batch = simulate_data()
840
+ combat = ComBat(batch=batch)
841
+ with pytest.raises(ValueError, match="not fitted"):
842
+ combat.plot_batch_effect_heatmap()
843
+
844
+
845
+ def test_set_params_updates_method():
846
+ """set_params must propagate to the model created at fit time."""
847
+ X, batch = simulate_data()
848
+ combat = ComBat(batch=batch, method="johnson")
849
+ combat.set_params(method="fortin")
850
+ combat.fit(X)
851
+ assert combat._model.method == "fortin"
@@ -1,143 +0,0 @@
1
- """Scikit-learn compatible ComBat wrapper."""
2
-
3
- from __future__ import annotations
4
-
5
- import numpy as np
6
- import pandas as pd
7
- from sklearn.base import BaseEstimator, TransformerMixin
8
-
9
- from .core import ArrayLike, ComBatModel
10
- from .metrics import ComBatMetricsMixin
11
- from .visualization import ComBatVisualizationMixin
12
-
13
-
14
- class ComBat(ComBatMetricsMixin, ComBatVisualizationMixin, BaseEstimator, TransformerMixin):
15
- """Pipeline-friendly wrapper around `ComBatModel`.
16
-
17
- Stores batch (and optional covariates) passed at construction and
18
- appropriately uses them for separate `fit` and `transform`.
19
-
20
- Parameters
21
- ----------
22
- batch : array-like of shape (n_samples,)
23
- Batch labels for each sample.
24
- discrete_covariates : array-like, optional
25
- Categorical covariates to protect (Fortin/Chen only).
26
- continuous_covariates : array-like, optional
27
- Continuous covariates to protect (Fortin/Chen only).
28
- method : {'johnson', 'fortin', 'chen'}, default='johnson'
29
- ComBat variant to use.
30
- parametric : bool, default=True
31
- Use parametric empirical Bayes.
32
- mean_only : bool, default=False
33
- Adjust only the mean (ignore variance).
34
- reference_batch : str, optional
35
- Batch level to leave unchanged.
36
- eps : float, default=1e-8
37
- Numerical jitter for stability.
38
- covbat_cov_thresh : float or int, default=0.9
39
- CovBat variance threshold for PCs.
40
- compute_metrics : bool, default=False
41
- If True, ``fit_transform`` caches batch metrics in ``metrics_``.
42
- """
43
-
44
- def __init__(
45
- self,
46
- batch: ArrayLike,
47
- *,
48
- discrete_covariates: ArrayLike | None = None,
49
- continuous_covariates: ArrayLike | None = None,
50
- method: str = "johnson",
51
- parametric: bool = True,
52
- mean_only: bool = False,
53
- reference_batch: str | None = None,
54
- eps: float = 1e-8,
55
- covbat_cov_thresh: float | int = 0.9,
56
- compute_metrics: bool = False,
57
- ) -> None:
58
- self.batch = batch
59
- self.discrete_covariates = discrete_covariates
60
- self.continuous_covariates = continuous_covariates
61
- self.method = method
62
- self.parametric = parametric
63
- self.mean_only = mean_only
64
- self.reference_batch = reference_batch
65
- self.eps = eps
66
- self.covbat_cov_thresh = covbat_cov_thresh
67
- self.compute_metrics = compute_metrics
68
- self._model = ComBatModel(
69
- method=method,
70
- parametric=parametric,
71
- mean_only=mean_only,
72
- reference_batch=reference_batch,
73
- eps=eps,
74
- covbat_cov_thresh=covbat_cov_thresh,
75
- )
76
-
77
- def fit(self, X: ArrayLike, y: ArrayLike | None = None) -> ComBat:
78
- """Fit the ComBat model."""
79
- idx = X.index if isinstance(X, pd.DataFrame) else pd.RangeIndex(len(X))
80
- batch_vec = self._subset(self.batch, idx)
81
- disc = self._subset(self.discrete_covariates, idx)
82
- cont = self._subset(self.continuous_covariates, idx)
83
- self._model.fit(
84
- X,
85
- batch=batch_vec,
86
- discrete_covariates=disc,
87
- continuous_covariates=cont,
88
- )
89
- self._fitted_batch = batch_vec
90
- return self
91
-
92
- def transform(self, X: ArrayLike) -> pd.DataFrame:
93
- """Transform the data using fitted ComBat parameters."""
94
- idx = X.index if isinstance(X, pd.DataFrame) else pd.RangeIndex(len(X))
95
- batch_vec = self._subset(self.batch, idx)
96
- disc = self._subset(self.discrete_covariates, idx)
97
- cont = self._subset(self.continuous_covariates, idx)
98
- return self._model.transform(
99
- X,
100
- batch=batch_vec,
101
- discrete_covariates=disc,
102
- continuous_covariates=cont,
103
- )
104
-
105
- @staticmethod
106
- def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
107
- """Subset array-like object by index."""
108
- if obj is None:
109
- return None
110
- if isinstance(obj, (pd.Series, pd.DataFrame)):
111
- return obj.loc[idx]
112
- else:
113
- if isinstance(obj, np.ndarray) and obj.ndim == 1:
114
- return pd.Series(obj, index=idx)
115
- else:
116
- return pd.DataFrame(obj, index=idx)
117
-
118
- def fit_transform(self, X: ArrayLike, y: ArrayLike | None = None) -> pd.DataFrame:
119
- """
120
- Fit and transform the data, optionally computing metrics.
121
-
122
- If ``compute_metrics=True`` was set at construction, batch effect
123
- metrics are computed and cached in the ``metrics_`` property.
124
-
125
- Parameters
126
- ----------
127
- X : array-like of shape (n_samples, n_features)
128
- Input data to fit and transform.
129
- y : None
130
- Ignored. Present for API compatibility.
131
-
132
- Returns
133
- -------
134
- X_transformed : pd.DataFrame
135
- Batch-corrected data.
136
- """
137
- self.fit(X, y)
138
- X_transformed = self.transform(X)
139
-
140
- if self.compute_metrics:
141
- self._metrics_cache = self.compute_batch_metrics(X)
142
-
143
- return X_transformed
File without changes
File without changes