combatlearn 1.4.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 (27) hide show
  1. {combatlearn-1.4.0 → combatlearn-2.0.0}/PKG-INFO +1 -1
  2. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn/__init__.py +1 -1
  3. combatlearn-2.0.0/combatlearn/_utils.py +21 -0
  4. combatlearn-2.0.0/combatlearn/inspection.py +194 -0
  5. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn/metrics.py +127 -214
  6. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn/sklearn_api.py +8 -126
  7. combatlearn-2.0.0/combatlearn/visualization.py +636 -0
  8. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn.egg-info/PKG-INFO +1 -1
  9. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn.egg-info/SOURCES.txt +6 -1
  10. combatlearn-2.0.0/tests/test_combat.py +477 -0
  11. combatlearn-2.0.0/tests/test_inspection.py +235 -0
  12. combatlearn-2.0.0/tests/test_metrics.py +107 -0
  13. combatlearn-2.0.0/tests/test_visualization.py +180 -0
  14. combatlearn-1.4.0/combatlearn/visualization.py +0 -609
  15. combatlearn-1.4.0/tests/test_combat.py +0 -1008
  16. {combatlearn-1.4.0 → combatlearn-2.0.0}/LICENSE +0 -0
  17. {combatlearn-1.4.0 → combatlearn-2.0.0}/README.md +0 -0
  18. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn/core.py +0 -0
  19. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn/py.typed +0 -0
  20. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  21. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn.egg-info/requires.txt +0 -0
  22. {combatlearn-1.4.0 → combatlearn-2.0.0}/combatlearn.egg-info/top_level.txt +0 -0
  23. {combatlearn-1.4.0 → combatlearn-2.0.0}/pyproject.toml +0 -0
  24. {combatlearn-1.4.0 → combatlearn-2.0.0}/requirements-dev.txt +0 -0
  25. {combatlearn-1.4.0 → combatlearn-2.0.0}/requirements-docs.txt +0 -0
  26. {combatlearn-1.4.0 → combatlearn-2.0.0}/requirements.txt +0 -0
  27. {combatlearn-1.4.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.4.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
@@ -1,5 +1,5 @@
1
1
  from .sklearn_api import ComBat
2
2
 
3
3
  __all__ = ["ComBat"]
4
- __version__ = "1.4.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)
@@ -0,0 +1,194 @@
1
+ """Standalone inspection functions for fitted ComBat models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ if TYPE_CHECKING:
11
+ from .sklearn_api import ComBat
12
+
13
+
14
+ def feature_batch_diagnostics(
15
+ combat: ComBat,
16
+ mode: Literal["magnitude", "distribution"] = "magnitude",
17
+ weighted: bool = True,
18
+ ) -> pd.DataFrame:
19
+ """Compute per-feature batch effect magnitude.
20
+
21
+ Returns a DataFrame with columns ``location``, ``scale``, and
22
+ ``combined``. Location is the (weighted) RMS of gamma across batches
23
+ (standardized mean shifts). Scale is the (weighted) RMS of log-delta
24
+ across batches (log-fold variance change). Combined is the Euclidean
25
+ norm sqrt(location**2 + scale**2). Using RMS provides L2-consistent
26
+ aggregation; using log(delta) ensures symmetry.
27
+
28
+ Parameters
29
+ ----------
30
+ combat : ComBat
31
+ A fitted ``ComBat`` instance.
32
+ mode : {'magnitude', 'distribution'}, default='magnitude'
33
+ - 'magnitude': Returns L2-consistent absolute batch effect magnitudes.
34
+ Suitable for ranking, thresholding, and cross-dataset comparison.
35
+ - 'distribution': Returns column-wise normalized proportions (each column
36
+ sums to 1, values in range [0, 1]), representing the relative contribution
37
+ of each feature to the total location, scale, or combined batch effect.
38
+ Note: normalization is applied independently to each column, so the
39
+ Euclidean relationship (combined**2 = location**2 + scale**2) no longer holds.
40
+ weighted : bool, default=True
41
+ If True, compute a weighted RMS where each batch is weighted by its
42
+ sample size. This gives more influence to larger batches, producing
43
+ a more statistically representative summary. If False, all batches
44
+ contribute equally regardless of size.
45
+
46
+ Returns
47
+ -------
48
+ pd.DataFrame
49
+ DataFrame with index=feature names, columns=['location', 'scale', 'combined'],
50
+ sorted by 'combined' descending.
51
+
52
+ Raises
53
+ ------
54
+ ValueError
55
+ If the model is not fitted or if mode is invalid.
56
+ """
57
+ if not hasattr(combat, "_model") or not hasattr(combat._model, "_gamma_star"):
58
+ raise ValueError(
59
+ "This ComBat instance is not fitted yet. Call 'fit' before 'feature_batch_diagnostics'."
60
+ )
61
+
62
+ if mode not in ["magnitude", "distribution"]:
63
+ raise ValueError(f"mode must be 'magnitude' or 'distribution', got '{mode}'")
64
+
65
+ feature_names = combat._model._grand_mean.index
66
+ gamma_star = combat._model._gamma_star
67
+ delta_star = combat._model._delta_star
68
+
69
+ if weighted:
70
+ # Batch sample sizes as weights, normalized to sum to 1
71
+ n_per_batch = combat._model._n_per_batch
72
+ weights = np.array(
73
+ [n_per_batch[str(lvl)] for lvl in combat._model._batch_levels], dtype=np.float64
74
+ )
75
+ weights = weights / weights.sum()
76
+
77
+ # Location effect: weighted RMS of gamma across batches
78
+ location = np.sqrt((weights[:, np.newaxis] * gamma_star**2).sum(axis=0))
79
+
80
+ # Scale effect: weighted RMS of log(delta) across batches
81
+ if not combat.mean_only:
82
+ scale = np.sqrt((weights[:, np.newaxis] * np.log(delta_star) ** 2).sum(axis=0))
83
+ else:
84
+ scale = np.zeros_like(location)
85
+ else:
86
+ # Location effect: unweighted RMS of gamma across batches
87
+ location = np.sqrt((gamma_star**2).mean(axis=0))
88
+
89
+ # Scale effect: unweighted RMS of log(delta) across batches
90
+ if not combat.mean_only:
91
+ scale = np.sqrt((np.log(delta_star) ** 2).mean(axis=0))
92
+ else:
93
+ scale = np.zeros_like(location)
94
+
95
+ # Euclidean to treat location and scale as orthogonal dimensions
96
+ combined = np.sqrt(location**2 + scale**2)
97
+
98
+ if mode == "distribution":
99
+ # Normalize each column independently to sum to 1
100
+ location_sum = location.sum()
101
+ scale_sum = scale.sum()
102
+ combined_sum = combined.sum()
103
+
104
+ location = location / location_sum if location_sum > 0 else location
105
+ scale = scale / scale_sum if scale_sum > 0 else scale
106
+ combined = combined / combined_sum if combined_sum > 0 else combined
107
+
108
+ return pd.DataFrame(
109
+ {
110
+ "location": location,
111
+ "scale": scale,
112
+ "combined": combined,
113
+ },
114
+ index=feature_names,
115
+ ).sort_values("combined", ascending=False)
116
+
117
+
118
+ def summary(combat: ComBat) -> str:
119
+ """Return a human-readable diagnostic report after fitting.
120
+
121
+ Parameters
122
+ ----------
123
+ combat : ComBat
124
+ A fitted ``ComBat`` instance.
125
+
126
+ Returns
127
+ -------
128
+ str
129
+ Multi-line summary string.
130
+
131
+ Raises
132
+ ------
133
+ ValueError
134
+ If the model is not fitted.
135
+ """
136
+ if not hasattr(combat, "_model") or not hasattr(combat._model, "_gamma_star"):
137
+ raise ValueError("This ComBat instance is not fitted yet. Call 'fit' before 'summary'.")
138
+
139
+ lines: list[str] = []
140
+ lines.append("ComBat Summary")
141
+ lines.append("=" * 40)
142
+ lines.append(f"Method: {combat.method}")
143
+ lines.append(f"Parametric: {combat.parametric}")
144
+ lines.append(f"Mean only: {combat.mean_only}")
145
+ lines.append(f"Reference batch: {combat.reference_batch or 'None'}")
146
+
147
+ batch_levels = combat._model._batch_levels
148
+ n_per_batch = combat._model._n_per_batch
149
+ lines.append(f"Number of batches: {len(batch_levels)}")
150
+ lines.append("Samples per batch:")
151
+ for lvl in batch_levels:
152
+ lines.append(f" {lvl}: {n_per_batch[str(lvl)]}")
153
+
154
+ n_features = len(combat._model._grand_mean)
155
+ lines.append(f"Number of features: {n_features}")
156
+
157
+ lines.append("")
158
+ lines.append("Top 5 features by batch effect (combined):")
159
+ importance = feature_batch_diagnostics(combat)
160
+ top5 = importance.head(5)
161
+ for feat, row in top5.iterrows():
162
+ lines.append(f" {feat}: {row['combined']:.4f}")
163
+
164
+ # Diagnostics table
165
+ lines.append("")
166
+ lines.append("Diagnostics")
167
+ lines.append("=" * 40)
168
+ col_w = 34
169
+ lines.append(f"{'Metric':<{col_w}}Value")
170
+ lines.append(f"{'------':<{col_w}}-----")
171
+
172
+ if hasattr(combat, "_batch_var_before_"):
173
+ lines.append(f"{'Batch var. explained (before)':<{col_w}}{combat._batch_var_before_:.1%}")
174
+ if hasattr(combat, "_batch_var_after_"):
175
+ lines.append(f"{'Batch var. explained (after)':<{col_w}}{combat._batch_var_after_:.1%}")
176
+
177
+ design_cond = getattr(combat._model, "_design_cond", None)
178
+ if design_cond is not None:
179
+ lines.append(f"{'Design matrix condition number':<{col_w}}{design_cond:.1f}")
180
+
181
+ conv_info = getattr(combat._model, "_convergence_info", [])
182
+ if conv_info:
183
+ eb_type = "parametric" if combat.parametric else "non-parametric"
184
+ lines.append(f"EB convergence ({eb_type}):")
185
+ for info in conv_info:
186
+ batch_name = info["batch"]
187
+ if info["converged"]:
188
+ status = f"converged ({info['iterations']} iter)"
189
+ else:
190
+ max_change = max(info["final_gamma_change"], info["final_delta_change"])
191
+ status = f"NOT CONVERGED ({info['iterations']} iter, \u0394={max_change:.2e})"
192
+ lines.append(f" {batch_name!s:<{col_w - 2}}{status}")
193
+
194
+ return "\n".join(lines)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Literal
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  import numpy as np
8
8
  import numpy.typing as npt
@@ -13,8 +13,12 @@ from sklearn.decomposition import PCA
13
13
  from sklearn.metrics import davies_bouldin_score, silhouette_score
14
14
  from sklearn.neighbors import NearestNeighbors
15
15
 
16
+ from ._utils import _subset
16
17
  from .core import ArrayLike
17
18
 
19
+ if TYPE_CHECKING:
20
+ from .sklearn_api import ComBat
21
+
18
22
 
19
23
  def _compute_pca_embedding(
20
24
  X_before: npt.NDArray[Any],
@@ -623,225 +627,134 @@ def _compute_alignment_metrics(
623
627
  }
624
628
 
625
629
 
626
- class ComBatMetricsMixin:
627
- """Mixin providing batch effect metrics for the ComBat wrapper."""
628
-
629
- @property
630
- def metrics_(self) -> dict[str, Any] | None:
631
- """Return cached metrics from last fit_transform with compute_metrics=True.
632
-
633
- Returns
634
- -------
635
- dict or None
636
- Cached metrics dictionary, or None if no metrics have been computed.
637
- """
638
- return getattr(self, "_metrics_cache", None)
639
-
640
- def compute_batch_metrics(
641
- self,
642
- X: ArrayLike,
643
- batch: ArrayLike | None = None,
644
- *,
645
- pca_components: int | None = None,
646
- k_neighbors: list[int] | None = None,
647
- kbet_k0: int | None = None,
648
- lisi_perplexity: int = 30,
649
- n_jobs: int = 1,
650
- nn_algorithm: str = "auto",
651
- ) -> dict[str, Any]:
652
- """
653
- Compute batch effect metrics before and after ComBat correction.
654
-
655
- Parameters
656
- ----------
657
- X : array-like of shape (n_samples, n_features)
658
- Input data to evaluate.
659
- batch : array-like of shape (n_samples,), optional
660
- Batch labels. If None, uses the batch stored at construction.
661
- pca_components : int, optional
662
- Number of PCA components for dimensionality reduction before
663
- computing metrics. If None (default), metrics are computed in
664
- the original feature space. Must be less than min(n_samples, n_features).
665
- k_neighbors : list of int, default=[5, 10, 50]
666
- Values of k for k-NN preservation metric.
667
- kbet_k0 : int, optional
668
- Neighborhood size for kBET. Default is 10% of samples.
669
- lisi_perplexity : int, default=30
670
- Perplexity for LISI computation.
671
- n_jobs : int, default=1
672
- Number of parallel jobs for neighbor computations.
673
- nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
674
- Algorithm used for nearest neighbor computation. Passed to
675
- ``sklearn.neighbors.NearestNeighbors``.
676
-
677
- Returns
678
- -------
679
- dict
680
- Dictionary with three main keys:
681
-
682
- - ``batch_effect``: Silhouette, Davies-Bouldin, kBET, LISI, variance ratio
683
- (each with 'before' and 'after' values)
684
- - ``preservation``: k-NN preservation fractions, distance correlation
685
- - ``alignment``: Centroid distance, Levene statistic (each with
686
- 'before' and 'after' values)
687
-
688
- Raises
689
- ------
690
- ValueError
691
- If the model is not fitted or if pca_components is invalid.
692
- """
693
- _valid_nn = {"auto", "ball_tree", "kd_tree", "brute"}
694
- if nn_algorithm not in _valid_nn:
695
- raise ValueError(f"nn_algorithm must be one of {_valid_nn}, got '{nn_algorithm}'")
696
-
697
- if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
698
- raise ValueError(
699
- "This ComBat instance is not fitted yet. Call 'fit' before 'compute_batch_metrics'."
700
- )
630
+ def compute_batch_metrics(
631
+ combat: ComBat,
632
+ X: ArrayLike,
633
+ batch: ArrayLike | None = None,
634
+ *,
635
+ pca_components: int | None = None,
636
+ k_neighbors: list[int] | None = None,
637
+ kbet_k0: int | None = None,
638
+ lisi_perplexity: int = 30,
639
+ n_jobs: int = 1,
640
+ nn_algorithm: str = "auto",
641
+ ) -> dict[str, Any]:
642
+ """
643
+ Compute batch effect metrics before and after ComBat correction.
701
644
 
702
- if not isinstance(X, pd.DataFrame):
703
- X = pd.DataFrame(X)
645
+ Parameters
646
+ ----------
647
+ combat : ComBat
648
+ A fitted ``ComBat`` instance.
649
+ X : array-like of shape (n_samples, n_features)
650
+ Input data to evaluate.
651
+ batch : array-like of shape (n_samples,), optional
652
+ Batch labels. If None, uses the batch stored at construction.
653
+ pca_components : int, optional
654
+ Number of PCA components for dimensionality reduction before
655
+ computing metrics. If None (default), metrics are computed in
656
+ the original feature space. Must be less than min(n_samples, n_features).
657
+ k_neighbors : list of int, default=[5, 10, 50]
658
+ Values of k for k-NN preservation metric.
659
+ kbet_k0 : int, optional
660
+ Neighborhood size for kBET. Default is 10% of samples.
661
+ lisi_perplexity : int, default=30
662
+ Perplexity for LISI computation.
663
+ n_jobs : int, default=1
664
+ Number of parallel jobs for neighbor computations.
665
+ nn_algorithm : {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'
666
+ Algorithm used for nearest neighbor computation. Passed to
667
+ ``sklearn.neighbors.NearestNeighbors``.
704
668
 
705
- idx = X.index
669
+ Returns
670
+ -------
671
+ dict
672
+ Dictionary with three main keys:
673
+
674
+ - ``batch_effect``: Silhouette, Davies-Bouldin, kBET, LISI, variance ratio
675
+ (each with 'before' and 'after' values)
676
+ - ``preservation``: k-NN preservation fractions, distance correlation
677
+ - ``alignment``: Centroid distance, Levene statistic (each with
678
+ 'before' and 'after' values)
679
+
680
+ Raises
681
+ ------
682
+ ValueError
683
+ If the model is not fitted or if pca_components is invalid.
684
+ """
685
+ _valid_nn = {"auto", "ball_tree", "kd_tree", "brute"}
686
+ if nn_algorithm not in _valid_nn:
687
+ raise ValueError(f"nn_algorithm must be one of {_valid_nn}, got '{nn_algorithm}'")
706
688
 
707
- if batch is None:
708
- batch_vec = self._subset(self.batch, idx) # type: ignore[attr-defined]
709
- else:
710
- if isinstance(batch, pd.Series | pd.DataFrame):
711
- batch_vec = batch.loc[idx] if hasattr(batch, "loc") else batch
712
- elif isinstance(batch, np.ndarray):
713
- batch_vec = pd.Series(batch, index=idx)
714
- else:
715
- batch_vec = pd.Series(batch, index=idx)
716
-
717
- batch_labels = np.array(batch_vec)
718
-
719
- X_before = X.values
720
- X_after = self.transform(X).values # type: ignore[attr-defined]
721
-
722
- n_samples, n_features = X_before.shape
723
- if kbet_k0 is None:
724
- kbet_k0 = max(10, int(0.10 * n_samples))
725
- if k_neighbors is None:
726
- k_neighbors = [5, 10, 50]
727
-
728
- # Validate and apply PCA if requested
729
- if pca_components is not None:
730
- max_components = min(n_samples, n_features)
731
- if pca_components >= max_components:
732
- raise ValueError(
733
- f"pca_components={pca_components} must be less than "
734
- f"min(n_samples, n_features)={max_components}."
735
- )
736
- X_before_pca, X_after_pca, _ = _compute_pca_embedding(X_before, X_after, pca_components)
737
- else:
738
- X_before_pca = X_before
739
- X_after_pca = X_after
740
-
741
- batch_effect = _compute_batch_effect_metrics(
742
- X_before_pca,
743
- X_after_pca,
744
- batch_labels,
745
- kbet_k0=kbet_k0,
746
- lisi_perplexity=lisi_perplexity,
747
- nn_algorithm=nn_algorithm,
748
- )
749
- preservation = _compute_preservation_metrics(
750
- X_before_pca,
751
- X_after_pca,
752
- k_neighbors,
753
- n_jobs,
754
- nn_algorithm=nn_algorithm,
755
- )
756
- alignment = _compute_alignment_metrics(
757
- X_before,
758
- X_after,
759
- X_before_pca,
760
- X_after_pca,
761
- batch_labels,
689
+ if not hasattr(combat, "_model") or not hasattr(combat._model, "_gamma_star"):
690
+ raise ValueError(
691
+ "This ComBat instance is not fitted yet. Call 'fit' before 'compute_batch_metrics'."
762
692
  )
763
693
 
764
- return {
765
- "batch_effect": batch_effect,
766
- "preservation": preservation,
767
- "alignment": alignment,
768
- }
769
-
770
- def feature_batch_importance(
771
- self,
772
- mode: Literal["magnitude", "distribution"] = "magnitude",
773
- ) -> pd.DataFrame:
774
- """Compute per-feature batch effect magnitude.
775
-
776
- Returns a DataFrame with columns ``location``, ``scale``, and
777
- ``combined``. Location is the RMS of gamma across batches
778
- (standardized mean shifts). Scale is the RMS of log-delta across
779
- batches (log-fold variance change). Combined is the Euclidean norm
780
- sqrt(location**2 + scale**2). Using RMS provides L2-consistent
781
- aggregation; using log(delta) ensures symmetry.
782
-
783
- Parameters
784
- ----------
785
- mode : {'magnitude', 'distribution'}, default='magnitude'
786
- - 'magnitude': Returns L2-consistent absolute batch effect magnitudes.
787
- Suitable for ranking, thresholding, and cross-dataset comparison.
788
- - 'distribution': Returns column-wise normalized proportions (each column
789
- sums to 1, values in range [0, 1]), representing the relative contribution
790
- of each feature to the total location, scale, or combined batch effect.
791
- Note: normalization is applied independently to each column, so the
792
- Euclidean relationship (combined**2 = location**2 + scale**2) no longer holds.
793
-
794
- Returns
795
- -------
796
- pd.DataFrame
797
- DataFrame with index=feature names, columns=['location', 'scale', 'combined'],
798
- sorted by 'combined' descending.
799
-
800
- Raises
801
- ------
802
- ValueError
803
- If the model is not fitted or if mode is invalid.
804
- """
805
- if not hasattr(self, "_model") or not hasattr(self._model, "_gamma_star"):
806
- raise ValueError(
807
- "This ComBat instance is not fitted yet. "
808
- "Call 'fit' before 'feature_batch_importance'."
809
- )
694
+ if not isinstance(X, pd.DataFrame):
695
+ X = pd.DataFrame(X)
810
696
 
811
- if mode not in ["magnitude", "distribution"]:
812
- raise ValueError(f"mode must be 'magnitude' or 'distribution', got '{mode}'")
697
+ idx = X.index
813
698
 
814
- feature_names = self._model._grand_mean.index
815
- gamma_star = self._model._gamma_star
816
- delta_star = self._model._delta_star
699
+ if batch is None:
700
+ batch_vec = _subset(combat.batch, idx)
701
+ else:
702
+ if isinstance(batch, pd.Series | pd.DataFrame):
703
+ batch_vec = batch.loc[idx] if hasattr(batch, "loc") else batch
704
+ elif isinstance(batch, np.ndarray):
705
+ batch_vec = pd.Series(batch, index=idx)
706
+ else:
707
+ batch_vec = pd.Series(batch, index=idx)
817
708
 
818
- # Location effect: RMS of gamma across batches (L2 aggregation)
819
- location = np.sqrt((gamma_star**2).mean(axis=0))
709
+ batch_labels = np.array(batch_vec)
820
710
 
821
- # Scale effect: RMS of log(delta) across batches
822
- if not self.mean_only: # type: ignore[attr-defined]
823
- scale = np.sqrt((np.log(delta_star) ** 2).mean(axis=0))
824
- else:
825
- scale = np.zeros_like(location)
826
-
827
- # Euclidean to treat location and scale as orthogonal dimensions
828
- combined = np.sqrt(location**2 + scale**2)
829
-
830
- if mode == "distribution":
831
- # Normalize each column independently to sum to 1
832
- location_sum = location.sum()
833
- scale_sum = scale.sum()
834
- combined_sum = combined.sum()
835
-
836
- location = location / location_sum if location_sum > 0 else location
837
- scale = scale / scale_sum if scale_sum > 0 else scale
838
- combined = combined / combined_sum if combined_sum > 0 else combined
839
-
840
- return pd.DataFrame(
841
- {
842
- "location": location,
843
- "scale": scale,
844
- "combined": combined,
845
- },
846
- index=feature_names,
847
- ).sort_values("combined", ascending=False)
711
+ X_before = X.values
712
+ X_after = combat.transform(X).values
713
+
714
+ n_samples, n_features = X_before.shape
715
+ if kbet_k0 is None:
716
+ kbet_k0 = max(10, int(0.10 * n_samples))
717
+ if k_neighbors is None:
718
+ k_neighbors = [5, 10, 50]
719
+
720
+ # Validate and apply PCA if requested
721
+ if pca_components is not None:
722
+ max_components = min(n_samples, n_features)
723
+ if pca_components >= max_components:
724
+ raise ValueError(
725
+ f"pca_components={pca_components} must be less than "
726
+ f"min(n_samples, n_features)={max_components}."
727
+ )
728
+ X_before_pca, X_after_pca, _ = _compute_pca_embedding(X_before, X_after, pca_components)
729
+ else:
730
+ X_before_pca = X_before
731
+ X_after_pca = X_after
732
+
733
+ batch_effect = _compute_batch_effect_metrics(
734
+ X_before_pca,
735
+ X_after_pca,
736
+ batch_labels,
737
+ kbet_k0=kbet_k0,
738
+ lisi_perplexity=lisi_perplexity,
739
+ nn_algorithm=nn_algorithm,
740
+ )
741
+ preservation = _compute_preservation_metrics(
742
+ X_before_pca,
743
+ X_after_pca,
744
+ k_neighbors,
745
+ n_jobs,
746
+ nn_algorithm=nn_algorithm,
747
+ )
748
+ alignment = _compute_alignment_metrics(
749
+ X_before,
750
+ X_after,
751
+ X_before_pca,
752
+ X_after_pca,
753
+ batch_labels,
754
+ )
755
+
756
+ return {
757
+ "batch_effect": batch_effect,
758
+ "preservation": preservation,
759
+ "alignment": alignment,
760
+ }