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,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
|
+
]
|