MaldiBatchKit 0.1.0__py3-none-any.whl
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.
- maldibatchkit/__init__.py +56 -0
- maldibatchkit/_base.py +176 -0
- maldibatchkit/_cli_io.py +211 -0
- maldibatchkit/_utils.py +122 -0
- maldibatchkit/cli.py +497 -0
- maldibatchkit/corrections/__init__.py +20 -0
- maldibatchkit/corrections/baselines.py +235 -0
- maldibatchkit/corrections/combat.py +84 -0
- maldibatchkit/corrections/harmony.py +445 -0
- maldibatchkit/corrections/limma.py +165 -0
- maldibatchkit/corrections/maldi.py +167 -0
- maldibatchkit/corrections/quality_weighted.py +338 -0
- maldibatchkit/diagnostics/__init__.py +39 -0
- maldibatchkit/diagnostics/generic.py +327 -0
- maldibatchkit/diagnostics/maldi.py +165 -0
- maldibatchkit/diagnostics/report.py +151 -0
- maldibatchkit/integrations/__init__.py +9 -0
- maldibatchkit/integrations/maldiset.py +184 -0
- maldibatchkit/py.typed +0 -0
- maldibatchkit/viz/__init__.py +11 -0
- maldibatchkit/viz/peaks.py +82 -0
- maldibatchkit/viz/summary.py +155 -0
- maldibatchkit/viz/umap.py +118 -0
- maldibatchkit-0.1.0.dist-info/METADATA +344 -0
- maldibatchkit-0.1.0.dist-info/RECORD +29 -0
- maldibatchkit-0.1.0.dist-info/WHEEL +5 -0
- maldibatchkit-0.1.0.dist-info/entry_points.txt +2 -0
- maldibatchkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- maldibatchkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""``MaldiSet`` <-> MaldiBatchKit bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from copy import copy
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover - type-checking imports only
|
|
13
|
+
from maldiamrkit import MaldiSet
|
|
14
|
+
|
|
15
|
+
__all__ = ["MaldiSetAdapter"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_maldiset():
|
|
19
|
+
try:
|
|
20
|
+
from maldiamrkit import MaldiSet
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"MaldiSetAdapter requires `maldiamrkit` (normally installed "
|
|
24
|
+
"alongside MaldiBatchKit). Reinstall with "
|
|
25
|
+
"`pip install -U maldibatchkit` or `pip install maldiamrkit`."
|
|
26
|
+
) from exc
|
|
27
|
+
return MaldiSet
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MaldiSetAdapter:
|
|
31
|
+
"""Bridge :class:`maldiamrkit.MaldiSet` and MaldiBatchKit correctors.
|
|
32
|
+
|
|
33
|
+
The adapter does three things:
|
|
34
|
+
|
|
35
|
+
1. Extracts the feature matrix ``ds.X`` and any metadata columns
|
|
36
|
+
(batch / species / quality) from a ``MaldiSet``.
|
|
37
|
+
2. Delegates to a MaldiBatchKit corrector (or any sklearn-compatible
|
|
38
|
+
transformer with the right constructor signature) to produce a
|
|
39
|
+
corrected matrix.
|
|
40
|
+
3. Returns a **new** ``MaldiSet`` whose ``X`` property yields the
|
|
41
|
+
corrected matrix, leaving the original dataset untouched.
|
|
42
|
+
|
|
43
|
+
Batch / covariate slicing follows the rest of the package: pass the
|
|
44
|
+
metadata column names at construction time; the adapter pulls the
|
|
45
|
+
aligned series from ``ds.meta`` itself so users do not have to
|
|
46
|
+
rebuild arrays manually.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
batch_column : str
|
|
51
|
+
Column in ``ds.meta`` with the batch labels.
|
|
52
|
+
species_column : str, optional
|
|
53
|
+
Column in ``ds.meta`` with species labels (used when the chosen
|
|
54
|
+
corrector needs a species covariate).
|
|
55
|
+
quality_column : str, optional
|
|
56
|
+
Column in ``ds.meta`` with per-sample quality scores (used by
|
|
57
|
+
:class:`maldibatchkit.QualityWeightedComBat`).
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
>>> from maldiamrkit import MaldiSet
|
|
62
|
+
>>> from maldibatchkit.integrations import MaldiSetAdapter
|
|
63
|
+
>>> from maldibatchkit import SpeciesAwareComBat
|
|
64
|
+
>>> adapter = MaldiSetAdapter(batch_column="Batch", species_column="Species")
|
|
65
|
+
>>> corrected_ds = adapter.correct(ds, SpeciesAwareComBat)
|
|
66
|
+
>>> corrected_ds.X.head() # corrected feature matrix
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
batch_column: str,
|
|
73
|
+
species_column: str | None = None,
|
|
74
|
+
quality_column: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.batch_column = batch_column
|
|
77
|
+
self.species_column = species_column
|
|
78
|
+
self.quality_column = quality_column
|
|
79
|
+
|
|
80
|
+
def extract(self, ds: MaldiSet) -> dict[str, Any]:
|
|
81
|
+
"""Pull ``X``, batch, species, quality from a ``MaldiSet``.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
dict
|
|
86
|
+
Dictionary with keys ``X`` (DataFrame), ``batch``,
|
|
87
|
+
``species`` (or ``None``), ``quality`` (or ``None``). The
|
|
88
|
+
series are aligned to ``X.index``.
|
|
89
|
+
"""
|
|
90
|
+
_require_maldiset()
|
|
91
|
+
X = ds.X
|
|
92
|
+
idx = X.index
|
|
93
|
+
meta = ds.meta.loc[idx]
|
|
94
|
+
if self.batch_column not in meta.columns:
|
|
95
|
+
raise KeyError(
|
|
96
|
+
f"batch_column={self.batch_column!r} not found in MaldiSet metadata."
|
|
97
|
+
)
|
|
98
|
+
batch = meta[self.batch_column]
|
|
99
|
+
species = (
|
|
100
|
+
meta[self.species_column]
|
|
101
|
+
if self.species_column and self.species_column in meta.columns
|
|
102
|
+
else None
|
|
103
|
+
)
|
|
104
|
+
quality = (
|
|
105
|
+
meta[self.quality_column]
|
|
106
|
+
if self.quality_column and self.quality_column in meta.columns
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
return {"X": X, "batch": batch, "species": species, "quality": quality}
|
|
110
|
+
|
|
111
|
+
def correct(
|
|
112
|
+
self,
|
|
113
|
+
ds: MaldiSet,
|
|
114
|
+
transformer_cls,
|
|
115
|
+
*,
|
|
116
|
+
transformer_kwargs: dict[str, Any] | None = None,
|
|
117
|
+
) -> MaldiSet:
|
|
118
|
+
"""Run ``transformer_cls(batch=..., ...)`` and return a new MaldiSet.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
ds : MaldiSet
|
|
123
|
+
Source dataset.
|
|
124
|
+
transformer_cls : type
|
|
125
|
+
A MaldiBatchKit corrector class (or any transformer whose
|
|
126
|
+
constructor takes ``batch=`` and, optionally, a
|
|
127
|
+
species-style protected-covariate argument named
|
|
128
|
+
``species`` / ``discrete_covariates`` / ``design`` /
|
|
129
|
+
``covariates`` and/or a ``quality=`` argument).
|
|
130
|
+
transformer_kwargs : dict, optional
|
|
131
|
+
Extra keyword arguments forwarded to ``transformer_cls``.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
MaldiSet
|
|
136
|
+
Shallow-copied dataset with its ``_X_cache`` replaced by the
|
|
137
|
+
corrected feature matrix. The ``spectra`` list is the same
|
|
138
|
+
object in the returned ``MaldiSet``; labels / metadata are
|
|
139
|
+
unchanged.
|
|
140
|
+
"""
|
|
141
|
+
_require_maldiset()
|
|
142
|
+
extracted = self.extract(ds)
|
|
143
|
+
X = extracted["X"]
|
|
144
|
+
batch = extracted["batch"]
|
|
145
|
+
species = extracted["species"]
|
|
146
|
+
quality = extracted["quality"]
|
|
147
|
+
|
|
148
|
+
kwargs: dict[str, Any] = {"batch": batch}
|
|
149
|
+
try:
|
|
150
|
+
init_params = set(inspect.signature(transformer_cls.__init__).parameters)
|
|
151
|
+
except (TypeError, ValueError): # pragma: no cover - exotic subclasses
|
|
152
|
+
init_params = set()
|
|
153
|
+
|
|
154
|
+
# Route species metadata to a *categorical* protected-covariate
|
|
155
|
+
# slot, in decreasing order of specificity: dedicated
|
|
156
|
+
# ``species=``, then ComBat / Fortin's ``discrete_covariates=``,
|
|
157
|
+
# then Harmony's ``covariates=``. We intentionally do NOT auto-
|
|
158
|
+
# route to Limma's ``design=`` (that slot is a numeric design
|
|
159
|
+
# matrix, not a label); Limma users who want to protect species
|
|
160
|
+
# should pass e.g. ``transformer_kwargs={"design": species_dummies}``.
|
|
161
|
+
if species is not None:
|
|
162
|
+
for name in ("species", "discrete_covariates", "covariates"):
|
|
163
|
+
if name in init_params:
|
|
164
|
+
kwargs[name] = species
|
|
165
|
+
break
|
|
166
|
+
if quality is not None and "quality" in init_params:
|
|
167
|
+
kwargs["quality"] = quality
|
|
168
|
+
if transformer_kwargs:
|
|
169
|
+
kwargs.update(transformer_kwargs)
|
|
170
|
+
|
|
171
|
+
transformer = transformer_cls(**kwargs)
|
|
172
|
+
X_corrected = transformer.fit_transform(X)
|
|
173
|
+
if not isinstance(X_corrected, pd.DataFrame):
|
|
174
|
+
X_corrected = pd.DataFrame(
|
|
175
|
+
np.asarray(X_corrected), index=X.index, columns=X.columns
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
new_ds = copy(ds)
|
|
179
|
+
# MaldiSet lazily rebuilds X from spectra unless _X_cache is set;
|
|
180
|
+
# inject the corrected matrix there so every downstream property
|
|
181
|
+
# (``.y``, ``.filter()``...) keeps working on the corrected data.
|
|
182
|
+
new_ds._X_cache = X_corrected
|
|
183
|
+
new_ds.meta = ds.meta.loc[X_corrected.index].copy()
|
|
184
|
+
return new_ds
|
maldibatchkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Visualization helpers for batch-effect inspection."""
|
|
2
|
+
|
|
3
|
+
from .peaks import plot_peak_shift
|
|
4
|
+
from .summary import plot_diagnostic_summary
|
|
5
|
+
from .umap import plot_batch_umap
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"plot_batch_umap",
|
|
9
|
+
"plot_diagnostic_summary",
|
|
10
|
+
"plot_peak_shift",
|
|
11
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Visualize per-batch peak position drift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .._utils import ArrayLike, _resolve_mz_axis
|
|
12
|
+
|
|
13
|
+
__all__ = ["plot_peak_shift"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_dataframe(X: ArrayLike) -> pd.DataFrame:
|
|
17
|
+
if isinstance(X, pd.DataFrame):
|
|
18
|
+
return X
|
|
19
|
+
return pd.DataFrame(np.asarray(X))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plot_peak_shift(
|
|
23
|
+
batches: ArrayLike,
|
|
24
|
+
X: ArrayLike,
|
|
25
|
+
reference: ArrayLike | None = None,
|
|
26
|
+
*,
|
|
27
|
+
mz_values: ArrayLike | None = None,
|
|
28
|
+
ax: Any | None = None,
|
|
29
|
+
max_batches: int | None = 6,
|
|
30
|
+
) -> tuple[Any, Any]:
|
|
31
|
+
"""Overlay per-batch median spectra against a reference spectrum.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
batches : array-like of shape (n_samples,)
|
|
36
|
+
Batch labels.
|
|
37
|
+
X : array-like of shape (n_samples, n_features)
|
|
38
|
+
Binned intensities.
|
|
39
|
+
reference : array-like of shape (n_features,), optional
|
|
40
|
+
Reference spectrum. If None, the global median across all rows
|
|
41
|
+
is used.
|
|
42
|
+
mz_values : array-like of shape (n_features,), optional
|
|
43
|
+
m/z coordinates for the x-axis. Defaults to column positions.
|
|
44
|
+
ax : matplotlib Axes, optional
|
|
45
|
+
Axis to draw on. If None, a new figure is created.
|
|
46
|
+
max_batches : int or None, default=6
|
|
47
|
+
Maximum number of batches to overlay. Oldest ties broken
|
|
48
|
+
alphabetically. Set to None to draw every batch (slow on
|
|
49
|
+
many-batch studies).
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
fig, ax : matplotlib Figure and Axes
|
|
54
|
+
"""
|
|
55
|
+
df = _to_dataframe(X).astype(float)
|
|
56
|
+
b = np.asarray(batches)
|
|
57
|
+
mz, mz_is_real = _resolve_mz_axis(df, mz_values)
|
|
58
|
+
|
|
59
|
+
if reference is None:
|
|
60
|
+
ref = df.median(axis=0).to_numpy()
|
|
61
|
+
else:
|
|
62
|
+
ref = np.asarray(reference, dtype=float).ravel()
|
|
63
|
+
|
|
64
|
+
levels = sorted(set(b.tolist()))
|
|
65
|
+
if max_batches is not None:
|
|
66
|
+
levels = levels[:max_batches]
|
|
67
|
+
|
|
68
|
+
if ax is None:
|
|
69
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
70
|
+
else:
|
|
71
|
+
fig = ax.figure
|
|
72
|
+
|
|
73
|
+
ax.plot(mz, ref, color="black", lw=1.3, label="reference", zorder=5)
|
|
74
|
+
cmap = plt.get_cmap("tab10")
|
|
75
|
+
for i, lvl in enumerate(levels):
|
|
76
|
+
med = df.loc[b == lvl].median(axis=0).to_numpy()
|
|
77
|
+
ax.plot(mz, med, lw=0.9, color=cmap(i % 10), label=str(lvl), alpha=0.85)
|
|
78
|
+
ax.set_xlabel("m/z" if mz_is_real else "bin index")
|
|
79
|
+
ax.set_ylabel("intensity (median per batch)")
|
|
80
|
+
ax.legend(title="batch", fontsize=7, loc="best")
|
|
81
|
+
ax.set_title("Per-batch peak-shape comparison")
|
|
82
|
+
return fig, ax
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Grid bar plot of before/after diagnostic metrics.
|
|
2
|
+
|
|
3
|
+
Different diagnostic metrics live on very different scales
|
|
4
|
+
(silhouette_batch ~ 0, LISI ~ 1-n_batches, kBET mean chi-square in the
|
|
5
|
+
tens to thousands...), so sharing a single y-axis makes the smaller
|
|
6
|
+
metrics unreadable. We render one subplot per metric, each with its
|
|
7
|
+
own y-axis.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
__all__ = ["plot_diagnostic_summary"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_BAR_BEFORE = "#b0b0b0"
|
|
22
|
+
_BAR_AFTER = "#2b8cbe"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def plot_diagnostic_summary(
|
|
26
|
+
report_df: pd.DataFrame,
|
|
27
|
+
*,
|
|
28
|
+
scope: str | Iterable[str] = "overall",
|
|
29
|
+
ncols: int | None = None,
|
|
30
|
+
figsize_per_plot: tuple[float, float] = (3.2, 3.0),
|
|
31
|
+
axes: Any | None = None,
|
|
32
|
+
) -> tuple[Any, np.ndarray]:
|
|
33
|
+
"""Plot before/after diagnostic values, one subplot per metric.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
report_df : pd.DataFrame
|
|
38
|
+
Output of :func:`maldibatchkit.diagnostics.diagnostic_report`.
|
|
39
|
+
scope : str or iterable of str, default='overall'
|
|
40
|
+
Which slice(s) of the report to plot. Pass a single scope name
|
|
41
|
+
(``"overall"``, ``"batch_00"``, ...) to render one pair of bars
|
|
42
|
+
per metric, or a list (``["batch_00", "batch_01"]``) to group
|
|
43
|
+
bars by scope inside each metric's subplot.
|
|
44
|
+
ncols : int, optional
|
|
45
|
+
Number of subplot columns. Defaults to ``min(n_metrics, 4)``.
|
|
46
|
+
figsize_per_plot : (float, float), default=(3.2, 3.0)
|
|
47
|
+
Width, height of each metric's subplot (inches). The returned
|
|
48
|
+
figure has size ``(ncols * w, nrows * h)``.
|
|
49
|
+
axes : array of matplotlib Axes, optional
|
|
50
|
+
Pre-built axes grid. Must have at least ``n_metrics`` entries
|
|
51
|
+
when flattened.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
fig : matplotlib.figure.Figure
|
|
56
|
+
axes : np.ndarray of matplotlib.axes.Axes
|
|
57
|
+
Flattened array of the axes actually used. Unused slots from a
|
|
58
|
+
non-rectangular grid are turned off.
|
|
59
|
+
"""
|
|
60
|
+
required = {"metric", "scope", "value_before", "value_after"}
|
|
61
|
+
missing = required - set(report_df.columns)
|
|
62
|
+
if missing:
|
|
63
|
+
raise ValueError(f"report_df is missing required columns: {sorted(missing)}.")
|
|
64
|
+
|
|
65
|
+
scopes = [scope] if isinstance(scope, str) else list(scope)
|
|
66
|
+
if not scopes:
|
|
67
|
+
raise ValueError("`scope` must name at least one scope.")
|
|
68
|
+
|
|
69
|
+
df = report_df[report_df["scope"].isin(scopes)].copy()
|
|
70
|
+
if df.empty:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"No rows in report_df with scope in {scopes!r}. "
|
|
73
|
+
f"Available scopes: {sorted(report_df['scope'].unique())}."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Preserve the metric order as it appears in the report
|
|
77
|
+
metrics: list[str] = []
|
|
78
|
+
for m in df["metric"].tolist():
|
|
79
|
+
if m not in metrics:
|
|
80
|
+
metrics.append(m)
|
|
81
|
+
|
|
82
|
+
n_metrics = len(metrics)
|
|
83
|
+
if ncols is None:
|
|
84
|
+
ncols = min(n_metrics, 4)
|
|
85
|
+
ncols = max(1, ncols)
|
|
86
|
+
nrows = int(np.ceil(n_metrics / ncols))
|
|
87
|
+
|
|
88
|
+
if axes is None:
|
|
89
|
+
fig, ax_grid = plt.subplots(
|
|
90
|
+
nrows,
|
|
91
|
+
ncols,
|
|
92
|
+
figsize=(ncols * figsize_per_plot[0], nrows * figsize_per_plot[1]),
|
|
93
|
+
)
|
|
94
|
+
ax_flat = np.atleast_1d(ax_grid).ravel()
|
|
95
|
+
else:
|
|
96
|
+
ax_flat = np.atleast_1d(np.asarray(axes)).ravel()
|
|
97
|
+
if ax_flat.size < n_metrics:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Provided axes has {ax_flat.size} slots but {n_metrics} "
|
|
100
|
+
f"metrics need plotting."
|
|
101
|
+
)
|
|
102
|
+
fig = ax_flat[0].figure
|
|
103
|
+
|
|
104
|
+
used_axes = []
|
|
105
|
+
for i, metric in enumerate(metrics):
|
|
106
|
+
ax = ax_flat[i]
|
|
107
|
+
sub = df[df["metric"] == metric]
|
|
108
|
+
scope_order = [s for s in scopes if s in sub["scope"].unique()]
|
|
109
|
+
if not scope_order:
|
|
110
|
+
ax.set_axis_off()
|
|
111
|
+
continue
|
|
112
|
+
sub = sub.set_index("scope").loc[scope_order]
|
|
113
|
+
n = len(scope_order)
|
|
114
|
+
xs = np.arange(n)
|
|
115
|
+
width = 0.38
|
|
116
|
+
ax.bar(
|
|
117
|
+
xs - width / 2,
|
|
118
|
+
sub["value_before"].astype(float).to_numpy(),
|
|
119
|
+
width,
|
|
120
|
+
label="before",
|
|
121
|
+
color=_BAR_BEFORE,
|
|
122
|
+
)
|
|
123
|
+
ax.bar(
|
|
124
|
+
xs + width / 2,
|
|
125
|
+
sub["value_after"].astype(float).to_numpy(),
|
|
126
|
+
width,
|
|
127
|
+
label="after",
|
|
128
|
+
color=_BAR_AFTER,
|
|
129
|
+
)
|
|
130
|
+
ax.set_xticks(xs)
|
|
131
|
+
if n > 1:
|
|
132
|
+
ax.set_xticklabels(scope_order, rotation=30, ha="right")
|
|
133
|
+
else:
|
|
134
|
+
ax.set_xticks([])
|
|
135
|
+
ax.set_title(metric, fontsize=10)
|
|
136
|
+
ax.grid(axis="y", alpha=0.3)
|
|
137
|
+
# Hairline guide at 0 for signed metrics
|
|
138
|
+
if sub[["value_before", "value_after"]].astype(float).min().min() < 0:
|
|
139
|
+
ax.axhline(0.0, color="k", lw=0.5)
|
|
140
|
+
if i == 0:
|
|
141
|
+
ax.legend(fontsize=8, loc="best")
|
|
142
|
+
used_axes.append(ax)
|
|
143
|
+
|
|
144
|
+
# Turn off any leftover subplot slots
|
|
145
|
+
for j in range(n_metrics, ax_flat.size):
|
|
146
|
+
ax_flat[j].set_axis_off()
|
|
147
|
+
|
|
148
|
+
title_suffix = scopes[0] if len(scopes) == 1 else f"{len(scopes)} scopes"
|
|
149
|
+
fig.suptitle(
|
|
150
|
+
f"Batch-effect diagnostics ({title_suffix})",
|
|
151
|
+
fontsize=12,
|
|
152
|
+
y=1.02,
|
|
153
|
+
)
|
|
154
|
+
fig.tight_layout()
|
|
155
|
+
return fig, ax_flat[:n_metrics]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Side-by-side UMAP before/after batch correction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.decomposition import PCA
|
|
10
|
+
|
|
11
|
+
from .._utils import ArrayLike
|
|
12
|
+
|
|
13
|
+
__all__ = ["plot_batch_umap"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require_umap():
|
|
17
|
+
try:
|
|
18
|
+
import umap # noqa: F401
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"plot_batch_umap requires `umap-learn`. Install the MaldiBatchKit "
|
|
22
|
+
"viz extras with `pip install maldibatchkit[viz]`."
|
|
23
|
+
) from exc
|
|
24
|
+
return umap
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _embed(X: np.ndarray, random_state: int) -> np.ndarray:
|
|
28
|
+
umap_mod = _require_umap()
|
|
29
|
+
reducer = umap_mod.UMAP(n_components=2, random_state=random_state)
|
|
30
|
+
return np.asarray(reducer.fit_transform(X))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_ndarray(X: ArrayLike) -> np.ndarray:
|
|
34
|
+
if hasattr(X, "to_numpy"):
|
|
35
|
+
return np.asarray(X.to_numpy())
|
|
36
|
+
return np.asarray(X)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plot_batch_umap(
|
|
40
|
+
before: ArrayLike,
|
|
41
|
+
after: ArrayLike,
|
|
42
|
+
batch: ArrayLike,
|
|
43
|
+
*,
|
|
44
|
+
color_by: str = "batch",
|
|
45
|
+
species: ArrayLike | None = None,
|
|
46
|
+
random_state: int = 42,
|
|
47
|
+
pca_preprocess: int | None = 50,
|
|
48
|
+
ax: tuple[Any, Any] | None = None,
|
|
49
|
+
) -> tuple[Any, Any]:
|
|
50
|
+
"""Plot UMAP embeddings of ``X`` before and after batch correction.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
before : array-like of shape (n_samples, n_features)
|
|
55
|
+
Feature matrix prior to correction.
|
|
56
|
+
after : array-like of shape (n_samples, n_features)
|
|
57
|
+
Feature matrix after correction. Must share ``before``'s shape.
|
|
58
|
+
batch : array-like of shape (n_samples,)
|
|
59
|
+
Batch labels.
|
|
60
|
+
color_by : {'batch', 'species'}, default='batch'
|
|
61
|
+
Which label drives colouring. ``'species'`` requires passing
|
|
62
|
+
``species`` explicitly.
|
|
63
|
+
species : array-like of shape (n_samples,), optional
|
|
64
|
+
Species labels. Required when ``color_by='species'``.
|
|
65
|
+
random_state : int, default=42
|
|
66
|
+
Seed for UMAP.
|
|
67
|
+
pca_preprocess : int or None, default=50
|
|
68
|
+
If not None, reduce to this many PCs before UMAP. Gives a large
|
|
69
|
+
speed-up on typical MALDI-TOF matrices without hurting the plot
|
|
70
|
+
qualitatively.
|
|
71
|
+
ax : tuple of matplotlib Axes, optional
|
|
72
|
+
Two axes to draw on. If None, a new 1x2 figure is created.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
fig : matplotlib.figure.Figure
|
|
77
|
+
The figure used (or the parent figure of the provided axes).
|
|
78
|
+
axes : tuple of matplotlib.axes.Axes
|
|
79
|
+
The two axes that were drawn on.
|
|
80
|
+
"""
|
|
81
|
+
before_arr = _to_ndarray(before).astype(float)
|
|
82
|
+
after_arr = _to_ndarray(after).astype(float)
|
|
83
|
+
if before_arr.shape != after_arr.shape:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"before and after must share shape; got {before_arr.shape} vs {after_arr.shape}."
|
|
86
|
+
)
|
|
87
|
+
if color_by == "species" and species is None:
|
|
88
|
+
raise ValueError("color_by='species' requires the `species` argument.")
|
|
89
|
+
labels = np.asarray(batch if color_by == "batch" else species)
|
|
90
|
+
|
|
91
|
+
def _maybe_pca(X: np.ndarray) -> np.ndarray:
|
|
92
|
+
if pca_preprocess is None:
|
|
93
|
+
return X
|
|
94
|
+
n = min(pca_preprocess, X.shape[0], X.shape[1])
|
|
95
|
+
if n < 2:
|
|
96
|
+
return X
|
|
97
|
+
return np.asarray(
|
|
98
|
+
PCA(n_components=n, random_state=random_state).fit_transform(X)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
b_emb = _embed(_maybe_pca(before_arr), random_state)
|
|
102
|
+
a_emb = _embed(_maybe_pca(after_arr), random_state)
|
|
103
|
+
|
|
104
|
+
if ax is None:
|
|
105
|
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharex=False, sharey=False)
|
|
106
|
+
else:
|
|
107
|
+
axes = ax
|
|
108
|
+
fig = axes[0].figure
|
|
109
|
+
|
|
110
|
+
for title, emb, ax_ in (("Before", b_emb, axes[0]), ("After", a_emb, axes[1])):
|
|
111
|
+
for lvl in np.unique(labels):
|
|
112
|
+
mask = labels == lvl
|
|
113
|
+
ax_.scatter(emb[mask, 0], emb[mask, 1], s=8, label=str(lvl), alpha=0.7)
|
|
114
|
+
ax_.set_title(f"UMAP - {title}")
|
|
115
|
+
ax_.set_xlabel("UMAP1")
|
|
116
|
+
ax_.set_ylabel("UMAP2")
|
|
117
|
+
axes[0].legend(title=color_by, loc="best", fontsize=7)
|
|
118
|
+
return fig, tuple(axes) if isinstance(axes, (list, np.ndarray)) else axes
|