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.
- {combatlearn-1.2.0 → combatlearn-1.3.0}/PKG-INFO +4 -3
- {combatlearn-1.2.0 → combatlearn-1.3.0}/README.md +1 -1
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn/__init__.py +1 -1
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn/core.py +43 -2
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn/metrics.py +37 -11
- combatlearn-1.3.0/combatlearn/sklearn_api.py +277 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn/visualization.py +113 -3
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn.egg-info/PKG-INFO +4 -3
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn.egg-info/requires.txt +2 -1
- {combatlearn-1.2.0 → combatlearn-1.3.0}/docs_requirements.txt +1 -1
- {combatlearn-1.2.0 → combatlearn-1.3.0}/pyproject.toml +0 -1
- {combatlearn-1.2.0 → combatlearn-1.3.0}/requirements.txt +1 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/tests/test_combat.py +196 -0
- combatlearn-1.2.0/combatlearn/sklearn_api.py +0 -143
- {combatlearn-1.2.0 → combatlearn-1.3.0}/LICENSE +0 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn.egg-info/SOURCES.txt +0 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn.egg-info/dependency_links.txt +0 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/combatlearn.egg-info/top_level.txt +0 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/dev_requirements.txt +0 -0
- {combatlearn-1.2.0 → combatlearn-1.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: combatlearn
|
|
3
|
-
Version: 1.
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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(
|
|
653
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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-
|
|
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
|
|
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-
|
|
18
|
+
myst-nb>=1.1.0
|
|
18
19
|
sphinx-copybutton>=0.5.0
|
|
19
20
|
linkify-it-py>=2.0.0
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|