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,167 @@
1
+ """MALDI-specific batch corrections that leverage ``maldiamrkit`` internals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ import pandas as pd
10
+
11
+ from .._base import BaseBatchCorrector
12
+ from .._utils import ArrayLike
13
+
14
+ __all__ = ["BatchAwareWarping"]
15
+
16
+
17
+ def _try_import_maldiamrkit():
18
+ try:
19
+ import maldiamrkit # noqa: F401
20
+ except ImportError as exc:
21
+ raise ImportError(
22
+ "This transformer requires 'maldiamrkit' (normally installed "
23
+ "alongside MaldiBatchKit). Reinstall with "
24
+ "`pip install -U maldibatchkit` or "
25
+ "`pip install maldiamrkit>=0.13.0`."
26
+ ) from exc
27
+ return maldiamrkit
28
+
29
+
30
+ class BatchAwareWarping(BaseBatchCorrector):
31
+ r"""Per-batch m/z warping to a single global reference.
32
+
33
+ This transformer reuses :class:`maldiamrkit.alignment.Warping` under
34
+ the hood, but fits one warper **per batch**. At ``fit`` time we
35
+ learn a global reference spectrum from all training rows, then fit a
36
+ dedicated ``Warping`` instance per batch with that shared reference.
37
+ At ``transform`` time each sample is routed through the warper of
38
+ its batch.
39
+
40
+ Why this matters
41
+ ----------------
42
+ Mass-calibration drift is usually a per-acquisition-session
43
+ phenomenon. Running a single global ``Warping`` can either be too
44
+ aggressive (squashing real biological peaks in well-calibrated
45
+ batches) or too conservative (leaving large shifts in a miscalibrated
46
+ batch). Per-batch warpers share the reference but learn a
47
+ batch-specific transformation.
48
+
49
+ Parameters
50
+ ----------
51
+ batch : array-like of shape (n_samples,)
52
+ Batch labels.
53
+ reference : str, default="median"
54
+ How to build the global reference.
55
+ ``"median"`` uses the median spectrum across all training rows.
56
+ ``"batch_mean"`` uses the mean of per-batch medians (reduces
57
+ bias towards large batches).
58
+ method : str, default="shift"
59
+ Warping method passed through to
60
+ ``maldiamrkit.alignment.Warping``.
61
+ n_segments : int, default=5
62
+ Piecewise warping segments.
63
+ max_shift : int, default=50
64
+ Maximum shift in bins.
65
+ dtw_radius : int, default=10
66
+ DTW radius constraint.
67
+ smooth_sigma : float, default=2.0
68
+ Gaussian smoothing for piecewise shifts.
69
+ n_jobs : int, default=1
70
+ Parallel jobs forwarded to ``Warping``.
71
+
72
+ Attributes
73
+ ----------
74
+ reference\_ : np.ndarray
75
+ The global reference spectrum.
76
+ warpers\_ : dict[Any, Warping]
77
+ One fitted ``Warping`` per batch level.
78
+
79
+ Notes
80
+ -----
81
+ For batches that do not appear at ``fit`` time but show up during
82
+ ``transform``, we fall back to a global warper trained on all
83
+ training rows (stored as :attr:`warpers_` under the ``"__global__"``
84
+ key). This keeps the transformer defined on test folds without
85
+ peeking at test data.
86
+
87
+ Examples
88
+ --------
89
+ >>> from maldibatchkit import BatchAwareWarping
90
+ >>> warper = BatchAwareWarping(batch=batches, method="piecewise")
91
+ >>> X_aligned = warper.fit_transform(X)
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ batch: ArrayLike,
97
+ *,
98
+ reference: str = "median",
99
+ method: str = "shift",
100
+ n_segments: int = 5,
101
+ max_shift: int = 50,
102
+ dtw_radius: int = 10,
103
+ smooth_sigma: float = 2.0,
104
+ n_jobs: int = 1,
105
+ ) -> None:
106
+ super().__init__(batch=batch)
107
+ self.reference = reference
108
+ self.method = method
109
+ self.n_segments = n_segments
110
+ self.max_shift = max_shift
111
+ self.dtw_radius = dtw_radius
112
+ self.smooth_sigma = smooth_sigma
113
+ self.n_jobs = n_jobs
114
+
115
+ def _make_warper(self):
116
+ _try_import_maldiamrkit()
117
+ from maldiamrkit.alignment import Warping # type: ignore[import-not-found]
118
+
119
+ return Warping(
120
+ reference="median",
121
+ method=self.method,
122
+ n_segments=self.n_segments,
123
+ max_shift=self.max_shift,
124
+ dtw_radius=self.dtw_radius,
125
+ smooth_sigma=self.smooth_sigma,
126
+ n_jobs=self.n_jobs,
127
+ )
128
+
129
+ def _build_reference(
130
+ self, X_df: pd.DataFrame, batch: npt.NDArray[Any]
131
+ ) -> np.ndarray:
132
+ if self.reference == "median":
133
+ return X_df.median(axis=0).to_numpy()
134
+ if self.reference == "batch_mean":
135
+ per_batch = []
136
+ for lvl in np.unique(batch):
137
+ per_batch.append(X_df.loc[batch == lvl].median(axis=0).to_numpy())
138
+ return np.mean(per_batch, axis=0)
139
+ raise ValueError(
140
+ f"Unknown reference={self.reference!r}; expected 'median' or 'batch_mean'."
141
+ )
142
+
143
+ def _fit_warper_on(self, df: pd.DataFrame, reference: np.ndarray):
144
+ w = self._make_warper()
145
+ w.fit(df)
146
+ w.ref_spec_ = reference # swap in the shared global reference
147
+ return w
148
+
149
+ def _fit_impl(self, X_df: pd.DataFrame, batch: npt.NDArray[Any]) -> None:
150
+ self.reference_ = self._build_reference(X_df, batch)
151
+ warpers: dict[Any, Any] = {}
152
+ for lvl in np.unique(batch):
153
+ df = X_df.loc[batch == lvl]
154
+ warpers[lvl] = self._fit_warper_on(df, self.reference_)
155
+ warpers["__global__"] = self._fit_warper_on(X_df, self.reference_)
156
+ self.warpers_ = warpers
157
+
158
+ def _transform_impl(
159
+ self, X_df: pd.DataFrame, batch: npt.NDArray[Any]
160
+ ) -> pd.DataFrame:
161
+ out = X_df.copy().astype(float)
162
+ for lvl in np.unique(batch):
163
+ mask = batch == lvl
164
+ chunk = X_df.loc[mask]
165
+ warper = self.warpers_.get(lvl, self.warpers_["__global__"])
166
+ out.loc[mask] = warper.transform(chunk).to_numpy()
167
+ return out
@@ -0,0 +1,338 @@
1
+ r"""Quality-weighted ComBat.
2
+
3
+ This module implements a *weighted* empirical-Bayes variant of the
4
+ classical Johnson-et-al. (2007) ComBat algorithm. Samples with higher
5
+ acquisition quality (typically a signal-to-noise ratio, or any
6
+ user-supplied scalar score) contribute proportionally more to the
7
+ batch-level moment estimates, and the shrinkage prior on each batch is
8
+ scaled by the effective (weighted) batch size rather than the raw
9
+ sample count.
10
+
11
+ Mathematical formulation
12
+ ------------------------
13
+ For feature :math:`g` in batch :math:`i`, let :math:`w_{ij} \\ge 0` be the
14
+ per-sample weight (weights are normalised so the weight vector sums to
15
+ the number of samples). Write :math:`\\bar w_i = \\sum_j w_{ij}` for the
16
+ effective batch size.
17
+
18
+ *Grand mean and pooled variance* are estimated with weights:
19
+
20
+ .. math::
21
+
22
+ \\hat\\alpha_g &= \\frac{\\sum_{i,j} w_{ij} X_{ij,g}}{\\sum_{i,j} w_{ij}}
23
+
24
+ \\hat\\sigma_g^2 &= \\frac{\\sum_{i,j} w_{ij} (X_{ij,g} - \\hat\\alpha_g)^2}
25
+ {\\sum_{i,j} w_{ij}}
26
+
27
+ *Per-batch L/S model.* After standardisation
28
+ :math:`Z_{ij,g} = (X_{ij,g} - \\hat\\alpha_g) / \\hat\\sigma_g`, the batch-
29
+ and-feature effects are estimated with weighted sums:
30
+
31
+ .. math::
32
+
33
+ \\hat\\gamma_{i,g} &= \\frac{\\sum_j w_{ij} Z_{ij,g}}{\\bar w_i}
34
+
35
+ \\hat\\delta_{i,g}^2 &= \\frac{\\sum_j w_{ij} (Z_{ij,g} - \\hat\\gamma_{i,g})^2}
36
+ {\\bar w_i - 1}
37
+
38
+ (``max(bar_w_i - 1, eps)`` is used to protect against tiny effective
39
+ batch sizes.)
40
+
41
+ *Empirical-Bayes priors.* The Normal-Inverse-Gamma hyperparameters
42
+ are fit by method-of-moments on the per-feature
43
+ :math:`\\hat\\gamma_{i,g}` (mean, variance) and per-feature
44
+ :math:`\\hat\\delta_{i,g}^2` (inverse-gamma :math:`\\alpha, \\beta`),
45
+ exactly as in classical ComBat.
46
+
47
+ *Posteriors.* The posterior mean and variance are then the usual
48
+ weighted combination of prior and data, but with the **effective** batch
49
+ size :math:`\\bar w_i` replacing :math:`n_i`:
50
+
51
+ .. math::
52
+
53
+ \\gamma^*_{i,g} &= \\frac{\\bar w_i \\bar\\tau_i^2 \\hat\\gamma_{i,g}
54
+ + \\delta^*_{i,g} \\bar\\gamma_i}
55
+ {\\bar w_i \\bar\\tau_i^2 + \\delta^*_{i,g}}
56
+
57
+ \\delta^{*2}_{i,g} &= \\frac{\\bar\\theta_i + 0.5 \\sum_j w_{ij}
58
+ (Z_{ij,g} - \\gamma^*_{i,g})^2}
59
+ {\\bar w_i / 2 + \\bar\\lambda_i - 1}
60
+
61
+ with :math:`\\bar\\gamma_i, \\bar\\tau_i^2, \\bar\\lambda_i, \\bar\\theta_i`
62
+ the prior hyperparameters fit as in Johnson et al. (2007). The
63
+ iterative scheme follows Johnson's original fixed-point updates; only
64
+ the summation weights differ.
65
+
66
+ Rationale
67
+ ---------
68
+ In MALDI-TOF AMR studies, not every spectrum is equally informative:
69
+ low-SNR acquisitions or spectra flagged by the lab QC are often kept in
70
+ the analysis to avoid discarding hard-won samples, but they should not
71
+ drive the per-batch correction. Down-weighting low-quality spectra in
72
+ the empirical-Bayes shrinkage keeps the posterior pulled towards the
73
+ estimate obtained from the high-quality spectra.
74
+
75
+ This corrector is **not** a replacement for more general covariate
76
+ modelling: like classical Johnson ComBat it does not preserve
77
+ biological signal encoded in a covariate. If you need that, either
78
+ residualise your covariate first or use
79
+ :class:`maldibatchkit.ComBat` with ``method='fortin'``.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ from typing import Any
85
+
86
+ import numpy as np
87
+ import numpy.typing as npt
88
+ import pandas as pd
89
+
90
+ from .._base import BaseBatchCorrector
91
+ from .._utils import ArrayLike, _subset
92
+
93
+ __all__ = ["QualityWeightedComBat"]
94
+
95
+
96
+ def _normalise_weights(
97
+ w: npt.NDArray[np.float64], *, eps: float
98
+ ) -> npt.NDArray[np.float64]:
99
+ w = np.asarray(w, dtype=float)
100
+ if np.any(w < 0):
101
+ raise ValueError("quality weights must be non-negative.")
102
+ if not np.any(w > 0):
103
+ raise ValueError("at least one quality weight must be positive.")
104
+ # Normalise so weights sum to the number of samples: equal weights
105
+ # should recover the unweighted estimates exactly.
106
+ w = w * (w.size / max(w.sum(), eps))
107
+ return w
108
+
109
+
110
+ def _aprior(gamma_hat: np.ndarray) -> np.ndarray:
111
+ """Method-of-moments hyperparameter for the gamma prior (per batch)."""
112
+ m = gamma_hat.mean(axis=1)
113
+ s2 = gamma_hat.var(axis=1, ddof=1)
114
+ return (2 * s2 + m**2) / np.clip(s2, 1e-12, None)
115
+
116
+
117
+ def _bprior(gamma_hat: np.ndarray) -> np.ndarray:
118
+ m = gamma_hat.mean(axis=1)
119
+ s2 = gamma_hat.var(axis=1, ddof=1)
120
+ return (m * s2 + m**3) / np.clip(s2, 1e-12, None)
121
+
122
+
123
+ def _postmean(
124
+ g_hat: np.ndarray,
125
+ g_bar: float,
126
+ n_bar: float,
127
+ t2: float,
128
+ d_star: np.ndarray,
129
+ ) -> np.ndarray:
130
+ return (n_bar * t2 * g_hat + d_star * g_bar) / (n_bar * t2 + d_star)
131
+
132
+
133
+ def _postvar(
134
+ sum_sq: np.ndarray,
135
+ n_bar: float,
136
+ a: float,
137
+ b: float,
138
+ ) -> np.ndarray:
139
+ return (0.5 * sum_sq + b) / (n_bar / 2.0 + a - 1.0)
140
+
141
+
142
+ class QualityWeightedComBat(BaseBatchCorrector):
143
+ r"""Weighted empirical-Bayes extension of Johnson-ComBat.
144
+
145
+ Parameters
146
+ ----------
147
+ batch : array-like of shape (n_samples,)
148
+ Batch labels.
149
+ quality : array-like of shape (n_samples,)
150
+ Non-negative per-sample quality scores (typically SNR). Higher
151
+ values mean a sample should contribute more to per-batch
152
+ estimation. Internally rescaled to sum to ``n_samples``.
153
+ parametric : bool, default=True
154
+ Use the parametric EB fixed-point iteration (as in Johnson
155
+ 2007). ``False`` falls back to the data estimates
156
+ :math:`\\hat\\gamma` / :math:`\\hat\\delta^2` directly - this is
157
+ useful as a sanity check.
158
+ reference_batch : Any, optional
159
+ Batch level to leave unchanged.
160
+ eps : float, default=1e-8
161
+ Numerical jitter.
162
+ max_iter : int, default=50
163
+ Hard cap on the parametric fixed-point iterations.
164
+ tol : float, default=1e-4
165
+ Convergence tolerance on the max absolute change in
166
+ :math:`\\gamma^*, \\delta^*`.
167
+
168
+ Attributes
169
+ ----------
170
+ batch_levels_ : np.ndarray
171
+ Batch levels observed at fit time.
172
+ grand_mean_ : np.ndarray
173
+ Weighted feature-wise grand mean (:math:`\\hat\\alpha`).
174
+ pooled_var_ : np.ndarray
175
+ Weighted feature-wise pooled variance (:math:`\\hat\\sigma^2`).
176
+ gamma_star_ : np.ndarray
177
+ Posterior batch means, shape ``(n_batches, n_features)``.
178
+ delta_star_ : np.ndarray
179
+ Posterior batch variances, shape ``(n_batches, n_features)``.
180
+ effective_batch_sizes_ : np.ndarray
181
+ :math:`\\bar w_i` per batch (weighted sample counts).
182
+ n_iter_ : int
183
+ Iterations actually taken by the parametric EB loop.
184
+
185
+ Examples
186
+ --------
187
+ >>> from maldibatchkit import QualityWeightedComBat
188
+ >>> corrector = QualityWeightedComBat(batch=batches, quality=snr)
189
+ >>> X_corrected = corrector.fit_transform(X)
190
+
191
+ References
192
+ ----------
193
+ Johnson, W.E., Li, C. and Rabinovic, A. (2007). "Adjusting batch
194
+ effects in microarray expression data using empirical Bayes
195
+ methods." *Biostatistics* 8(1): 118-127. - the unweighted
196
+ predecessor of the scheme implemented here.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ batch: ArrayLike,
202
+ *,
203
+ quality: ArrayLike,
204
+ parametric: bool = True,
205
+ reference_batch: Any | None = None,
206
+ eps: float = 1e-8,
207
+ max_iter: int = 50,
208
+ tol: float = 1e-4,
209
+ ) -> None:
210
+ super().__init__(batch=batch)
211
+ self.quality = quality
212
+ self.parametric = parametric
213
+ self.reference_batch = reference_batch
214
+ self.eps = eps
215
+ self.max_iter = max_iter
216
+ self.tol = tol
217
+
218
+ def _weights_at(self, idx: pd.Index) -> np.ndarray:
219
+ w = _subset(self.quality, idx)
220
+ if isinstance(w, pd.DataFrame):
221
+ if w.shape[1] != 1:
222
+ raise ValueError("quality must be 1-D.")
223
+ w = w.iloc[:, 0]
224
+ if w is None:
225
+ raise ValueError("quality weights must be provided.")
226
+ w = np.asarray(w, dtype=float)
227
+ return _normalise_weights(w, eps=self.eps)
228
+
229
+ def _fit_impl(self, X_df: pd.DataFrame, batch: npt.NDArray[Any]) -> None:
230
+ X = X_df.to_numpy(dtype=float)
231
+ n_samples, n_features = X.shape
232
+
233
+ w = self._weights_at(X_df.index) # shape (n,)
234
+ levels = np.unique(batch)
235
+ self.batch_levels_ = levels
236
+
237
+ if self.reference_batch is not None and self.reference_batch not in levels:
238
+ raise ValueError(
239
+ f"reference_batch={self.reference_batch!r} not found among "
240
+ f"training batch levels {list(levels)}."
241
+ )
242
+ self.reference_batch_ = self.reference_batch
243
+
244
+ W = w.sum()
245
+ self.grand_mean_ = (w[:, None] * X).sum(axis=0) / max(W, self.eps)
246
+ centred = X - self.grand_mean_
247
+ self.pooled_var_ = (w[:, None] * centred**2).sum(axis=0) / max(W, self.eps)
248
+ std = np.sqrt(self.pooled_var_ + self.eps)
249
+ Z = centred / std
250
+
251
+ n_batches = len(levels)
252
+ eff = np.zeros(n_batches)
253
+ gamma_hat = np.zeros((n_batches, n_features))
254
+ delta_hat2 = np.zeros((n_batches, n_features))
255
+
256
+ for i, lvl in enumerate(levels):
257
+ mask = batch == lvl
258
+ w_i = w[mask]
259
+ w_bar = float(w_i.sum())
260
+ eff[i] = w_bar
261
+ if w_bar < self.eps:
262
+ raise ValueError(
263
+ f"Effective batch size for {lvl!r} is ~0 "
264
+ f"(all quality weights are zero in this batch)."
265
+ )
266
+ g_hat = (w_i[:, None] * Z[mask]).sum(axis=0) / w_bar
267
+ gamma_hat[i] = g_hat
268
+ resid = Z[mask] - g_hat
269
+ denom = max(w_bar - 1.0, self.eps)
270
+ delta_hat2[i] = (w_i[:, None] * resid**2).sum(axis=0) / denom
271
+
272
+ self.effective_batch_sizes_ = eff
273
+
274
+ gamma_bar = gamma_hat.mean(axis=1)
275
+ t2 = gamma_hat.var(axis=1, ddof=1)
276
+ a_prior = _aprior(delta_hat2)
277
+ b_prior = _bprior(delta_hat2)
278
+
279
+ if not self.parametric:
280
+ gamma_star = gamma_hat
281
+ delta_star = delta_hat2
282
+ self.n_iter_ = 0
283
+ else:
284
+ gamma_star = gamma_hat.copy()
285
+ delta_star = delta_hat2.copy()
286
+ n_iter = 0
287
+ for _ in range(self.max_iter):
288
+ n_iter += 1
289
+ prev_g = gamma_star.copy()
290
+ prev_d = delta_star.copy()
291
+ for i, lvl in enumerate(levels):
292
+ mask = batch == lvl
293
+ w_i = w[mask]
294
+ w_bar = eff[i]
295
+ g_new = _postmean(
296
+ gamma_hat[i], gamma_bar[i], w_bar, t2[i], delta_star[i]
297
+ )
298
+ resid = Z[mask] - g_new
299
+ sum_sq = (w_i[:, None] * resid**2).sum(axis=0)
300
+ d_new = _postvar(sum_sq, w_bar, a_prior[i], b_prior[i])
301
+ gamma_star[i] = g_new
302
+ delta_star[i] = np.clip(d_new, self.eps, None)
303
+ change = max(
304
+ np.max(np.abs(gamma_star - prev_g)),
305
+ np.max(np.abs(delta_star - prev_d)),
306
+ )
307
+ if change < self.tol:
308
+ break
309
+ self.n_iter_ = n_iter
310
+
311
+ self.gamma_star_ = gamma_star
312
+ self.delta_star_ = delta_star
313
+
314
+ def _transform_impl(
315
+ self, X_df: pd.DataFrame, batch: npt.NDArray[Any]
316
+ ) -> pd.DataFrame:
317
+ X = X_df.to_numpy(dtype=float)
318
+ std = np.sqrt(self.pooled_var_ + self.eps)
319
+ Z = (X - self.grand_mean_) / std
320
+ out = np.empty_like(X)
321
+ level_to_idx = {lvl: i for i, lvl in enumerate(self.batch_levels_)}
322
+ for lvl in np.unique(batch):
323
+ mask = batch == lvl
324
+ if lvl == self.reference_batch_:
325
+ out[mask] = X[mask]
326
+ continue
327
+ if lvl in level_to_idx:
328
+ i = level_to_idx[lvl]
329
+ gamma = self.gamma_star_[i]
330
+ delta = self.delta_star_[i]
331
+ else:
332
+ # Unknown batch at transform time: correct using zero
333
+ # per-batch effect (identity in standardised space).
334
+ gamma = np.zeros(X.shape[1])
335
+ delta = np.ones(X.shape[1])
336
+ z_corr = (Z[mask] - gamma) / np.sqrt(np.clip(delta, self.eps, None))
337
+ out[mask] = z_corr * std + self.grand_mean_
338
+ return pd.DataFrame(out, index=X_df.index, columns=X_df.columns)
@@ -0,0 +1,39 @@
1
+ """Batch-effect diagnostics.
2
+
3
+ This subpackage exposes two families of metrics plus a convenience
4
+ "report" helper:
5
+
6
+ - :mod:`maldibatchkit.diagnostics.generic` - classic batch-mixing
7
+ metrics (k-nearest-neighbour batch test / kBET, local inverse
8
+ Simpson index / LISI, silhouette coefficient by batch).
9
+ - :mod:`maldibatchkit.diagnostics.maldi` - MALDI-specific summaries
10
+ (per-batch peak-position drift, per-batch TIC coefficient of
11
+ variation, per-batch spectrum count).
12
+ - :func:`maldibatchkit.diagnostics.diagnostic_report` - run every
13
+ metric on a ``(before, after)`` pair and summarise into a tidy
14
+ DataFrame.
15
+ """
16
+
17
+ from .generic import (
18
+ kbet,
19
+ lisi,
20
+ lisi_max,
21
+ lisi_normalized,
22
+ silhouette_batch,
23
+ species_preservation,
24
+ )
25
+ from .maldi import peak_position_drift, per_batch_spectrum_count, tic_cov_per_batch
26
+ from .report import diagnostic_report
27
+
28
+ __all__ = [
29
+ "diagnostic_report",
30
+ "kbet",
31
+ "lisi",
32
+ "lisi_max",
33
+ "lisi_normalized",
34
+ "peak_position_drift",
35
+ "per_batch_spectrum_count",
36
+ "silhouette_batch",
37
+ "species_preservation",
38
+ "tic_cov_per_batch",
39
+ ]