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.
@@ -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