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,327 @@
|
|
|
1
|
+
"""Generic batch-mixing metrics.
|
|
2
|
+
|
|
3
|
+
All functions in this module take a feature matrix ``X`` and a vector of
|
|
4
|
+
batch labels and return a single scalar (or an array keyed by k for
|
|
5
|
+
kBET) summarising how much batch structure remains after correction.
|
|
6
|
+
|
|
7
|
+
These metrics are intentionally batch-effect-specific: they say nothing
|
|
8
|
+
about whether biological signal is preserved. Pair them with supervised
|
|
9
|
+
metrics (AMR classifier AUROC, VME, etc.) in any real comparison.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import numpy.typing as npt
|
|
18
|
+
from scipy.stats import chi2
|
|
19
|
+
from sklearn.metrics import silhouette_score
|
|
20
|
+
from sklearn.neighbors import NearestNeighbors
|
|
21
|
+
|
|
22
|
+
from .._utils import ArrayLike
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"kbet",
|
|
26
|
+
"lisi",
|
|
27
|
+
"lisi_max",
|
|
28
|
+
"lisi_normalized",
|
|
29
|
+
"silhouette_batch",
|
|
30
|
+
"species_preservation",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _to_ndarray(X: ArrayLike) -> npt.NDArray[Any]:
|
|
35
|
+
if hasattr(X, "to_numpy"):
|
|
36
|
+
return np.asarray(X.to_numpy())
|
|
37
|
+
return np.asarray(X)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def silhouette_batch(
|
|
41
|
+
X: ArrayLike, batch: ArrayLike, *, metric: str = "euclidean"
|
|
42
|
+
) -> float:
|
|
43
|
+
"""Silhouette coefficient using batch labels as clusters.
|
|
44
|
+
|
|
45
|
+
Values close to 0 indicate good mixing; values close to 1 indicate
|
|
46
|
+
strong batch separation; values close to -1 indicate batches sitting
|
|
47
|
+
inside each others' clusters (typically an artefact).
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
X : array-like of shape (n_samples, n_features)
|
|
52
|
+
Feature matrix.
|
|
53
|
+
batch : array-like of shape (n_samples,)
|
|
54
|
+
Batch labels.
|
|
55
|
+
metric : str, default='euclidean'
|
|
56
|
+
Distance metric forwarded to ``sklearn.metrics.silhouette_score``.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
float
|
|
61
|
+
Silhouette coefficient, or ``0.0`` if there is fewer than two
|
|
62
|
+
distinct batches (silhouette is undefined in that case).
|
|
63
|
+
"""
|
|
64
|
+
Xa = _to_ndarray(X).astype(float)
|
|
65
|
+
batch_arr = np.asarray(batch)
|
|
66
|
+
if len(np.unique(batch_arr)) < 2:
|
|
67
|
+
return 0.0
|
|
68
|
+
return float(silhouette_score(Xa, batch_arr, metric=metric))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def kbet(
|
|
72
|
+
X: ArrayLike,
|
|
73
|
+
batch: ArrayLike,
|
|
74
|
+
*,
|
|
75
|
+
k: int | None = None,
|
|
76
|
+
alpha: float = 0.05,
|
|
77
|
+
) -> dict[str, float]:
|
|
78
|
+
"""k-nearest-neighbours Batch Effect Test (kBET; Büttner et al. 2019).
|
|
79
|
+
|
|
80
|
+
For each sample we compute a chi-square statistic testing whether
|
|
81
|
+
its k-nearest-neighbour batch composition matches the global batch
|
|
82
|
+
frequencies. The reported statistics are the acceptance rate (the
|
|
83
|
+
fraction of samples whose p-value exceeds ``alpha`` - higher is
|
|
84
|
+
better) and the mean chi-square statistic (lower is better).
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
X : array-like of shape (n_samples, n_features)
|
|
89
|
+
Feature matrix.
|
|
90
|
+
batch : array-like of shape (n_samples,)
|
|
91
|
+
Batch labels.
|
|
92
|
+
k : int, optional
|
|
93
|
+
Number of nearest neighbours. Defaults to
|
|
94
|
+
``max(10, int(0.1 * n_samples))``.
|
|
95
|
+
alpha : float, default=0.05
|
|
96
|
+
Significance threshold used to compute the acceptance rate.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
dict
|
|
101
|
+
``{"acceptance_rate": float, "mean_chi2": float, "k": int}``.
|
|
102
|
+
"""
|
|
103
|
+
Xa = _to_ndarray(X).astype(float)
|
|
104
|
+
batch_arr = np.asarray(batch)
|
|
105
|
+
n_samples = Xa.shape[0]
|
|
106
|
+
levels, counts = np.unique(batch_arr, return_counts=True)
|
|
107
|
+
n_batches = len(levels)
|
|
108
|
+
if n_batches < 2:
|
|
109
|
+
return {"acceptance_rate": 1.0, "mean_chi2": 0.0, "k": int(k or 0)}
|
|
110
|
+
if k is None:
|
|
111
|
+
k = max(10, int(0.1 * n_samples))
|
|
112
|
+
k = min(k, n_samples - 1)
|
|
113
|
+
global_freq = counts / counts.sum()
|
|
114
|
+
level_to_idx = {lvl: i for i, lvl in enumerate(levels)}
|
|
115
|
+
|
|
116
|
+
nn = NearestNeighbors(n_neighbors=k + 1).fit(Xa)
|
|
117
|
+
_, neigh_idx = nn.kneighbors(Xa)
|
|
118
|
+
neigh_idx = neigh_idx[:, 1:]
|
|
119
|
+
|
|
120
|
+
p_values = np.empty(n_samples)
|
|
121
|
+
chi2_stats = np.empty(n_samples)
|
|
122
|
+
for s in range(n_samples):
|
|
123
|
+
local_counts = np.zeros(n_batches)
|
|
124
|
+
for j in neigh_idx[s]:
|
|
125
|
+
local_counts[level_to_idx[batch_arr[j]]] += 1
|
|
126
|
+
expected = k * global_freq
|
|
127
|
+
chi2_stat = np.sum(
|
|
128
|
+
(local_counts - expected) ** 2 / np.clip(expected, 1e-9, None)
|
|
129
|
+
)
|
|
130
|
+
chi2_stats[s] = chi2_stat
|
|
131
|
+
p_values[s] = float(1.0 - chi2.cdf(chi2_stat, df=n_batches - 1))
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"acceptance_rate": float(np.mean(p_values > alpha)),
|
|
135
|
+
"mean_chi2": float(chi2_stats.mean()),
|
|
136
|
+
"k": int(k),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def lisi(
|
|
141
|
+
X: ArrayLike,
|
|
142
|
+
batch: ArrayLike,
|
|
143
|
+
*,
|
|
144
|
+
perplexity: float = 30.0,
|
|
145
|
+
) -> float:
|
|
146
|
+
"""Local Inverse Simpson's Index for batch mixing.
|
|
147
|
+
|
|
148
|
+
LISI is the effective number of batches represented in each sample's
|
|
149
|
+
local neighbourhood (Gaussian-kernel weighted to the requested
|
|
150
|
+
perplexity). The returned value is the median LISI across samples.
|
|
151
|
+
It lies in ``[1, n_batches]``; values close to ``n_batches`` indicate
|
|
152
|
+
strong mixing, values close to 1 indicate batch-segregated
|
|
153
|
+
neighbourhoods.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
X : array-like of shape (n_samples, n_features)
|
|
158
|
+
Feature matrix.
|
|
159
|
+
batch : array-like of shape (n_samples,)
|
|
160
|
+
Batch labels.
|
|
161
|
+
perplexity : float, default=30.0
|
|
162
|
+
Target perplexity of the Gaussian kernel - matches the
|
|
163
|
+
convention used in Korsunsky et al. (2019) and combatlearn.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
float
|
|
168
|
+
Median LISI across samples.
|
|
169
|
+
"""
|
|
170
|
+
Xa = _to_ndarray(X).astype(float)
|
|
171
|
+
batch_arr = np.asarray(batch)
|
|
172
|
+
n_samples = Xa.shape[0]
|
|
173
|
+
levels = np.unique(batch_arr)
|
|
174
|
+
n_batches = len(levels)
|
|
175
|
+
if n_batches < 2:
|
|
176
|
+
return 1.0
|
|
177
|
+
level_to_idx = {lvl: i for i, lvl in enumerate(levels)}
|
|
178
|
+
k = int(min(max(3 * perplexity, 10), n_samples - 1))
|
|
179
|
+
nn = NearestNeighbors(n_neighbors=k + 1).fit(Xa)
|
|
180
|
+
distances, neigh_idx = nn.kneighbors(Xa)
|
|
181
|
+
distances = distances[:, 1:]
|
|
182
|
+
neigh_idx = neigh_idx[:, 1:]
|
|
183
|
+
|
|
184
|
+
target = np.log(perplexity)
|
|
185
|
+
lisi_values = np.empty(n_samples)
|
|
186
|
+
for s in range(n_samples):
|
|
187
|
+
d = distances[s]
|
|
188
|
+
# Binary search for beta matching target perplexity
|
|
189
|
+
lo, hi = 1e-10, 1e10
|
|
190
|
+
beta = 1.0
|
|
191
|
+
for _ in range(50):
|
|
192
|
+
weights = np.exp(-d * beta)
|
|
193
|
+
Z = max(weights.sum(), 1e-12)
|
|
194
|
+
h = np.log(Z) + beta * np.sum(d * weights) / Z
|
|
195
|
+
if h > target:
|
|
196
|
+
lo = beta
|
|
197
|
+
beta = beta * 2 if hi == 1e10 else (beta + hi) / 2
|
|
198
|
+
else:
|
|
199
|
+
hi = beta
|
|
200
|
+
beta = (beta + lo) / 2
|
|
201
|
+
if abs(h - target) < 1e-5:
|
|
202
|
+
break
|
|
203
|
+
weights = np.exp(-d * beta)
|
|
204
|
+
weights /= max(weights.sum(), 1e-12)
|
|
205
|
+
probs = np.zeros(n_batches)
|
|
206
|
+
for w, j in zip(weights, neigh_idx[s], strict=True):
|
|
207
|
+
probs[level_to_idx[batch_arr[j]]] += w
|
|
208
|
+
simpson = float(np.sum(probs**2))
|
|
209
|
+
lisi_values[s] = 1.0 / max(simpson, 1e-12)
|
|
210
|
+
return float(np.median(lisi_values))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lisi_max(labels: ArrayLike) -> float:
|
|
214
|
+
r"""Theoretical maximum LISI under perfect mixing.
|
|
215
|
+
|
|
216
|
+
LISI is the inverse Simpson index of the local label distribution
|
|
217
|
+
in a sample's neighbourhood, so under *perfect* mixing (every
|
|
218
|
+
neighbourhood matches the global label frequencies) it equals the
|
|
219
|
+
inverse Simpson index of the global frequencies:
|
|
220
|
+
|
|
221
|
+
.. math::
|
|
222
|
+
|
|
223
|
+
\text{lisi}_{\max} = \frac{1}{\sum_i p_i^2}
|
|
224
|
+
|
|
225
|
+
where :math:`p_i` is the global fraction of label :math:`i`. For
|
|
226
|
+
perfectly balanced designs this equals the number of unique labels;
|
|
227
|
+
for **imbalanced** designs (e.g. DRIAMS-A dominating in a multi-site
|
|
228
|
+
cohort) the ceiling is strictly less than ``n_levels``, which is
|
|
229
|
+
why raw LISI numbers can mislead readers who expect them to
|
|
230
|
+
approach the level count.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
labels : array-like of shape (n_samples,)
|
|
235
|
+
Label vector (e.g. batch or species).
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
float
|
|
240
|
+
Inverse Simpson index of the global label frequencies, in the
|
|
241
|
+
range ``[1, n_levels]``. Returns ``1.0`` when the input has
|
|
242
|
+
zero or one unique level (LISI is degenerate in that case).
|
|
243
|
+
"""
|
|
244
|
+
arr = np.asarray(labels)
|
|
245
|
+
if arr.size == 0:
|
|
246
|
+
return 1.0
|
|
247
|
+
_, counts = np.unique(arr, return_counts=True)
|
|
248
|
+
if len(counts) < 2:
|
|
249
|
+
return 1.0
|
|
250
|
+
p = counts / counts.sum()
|
|
251
|
+
return float(1.0 / float((p**2).sum()))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def lisi_normalized(
|
|
255
|
+
X: ArrayLike, labels: ArrayLike, *, perplexity: float = 30.0
|
|
256
|
+
) -> float:
|
|
257
|
+
"""LISI divided by its theoretical maximum.
|
|
258
|
+
|
|
259
|
+
Reports how close the local mixing is to *perfect* mixing given
|
|
260
|
+
the global label frequencies, on a ``[0, 1]`` scale where higher
|
|
261
|
+
means better mixed. This is the right way to compare LISI across
|
|
262
|
+
cohorts with different label-imbalance profiles -- raw LISI is
|
|
263
|
+
bounded by ``lisi_max(labels)`` (the inverse Simpson index of the
|
|
264
|
+
global label frequencies), not by the number of unique labels.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
X : array-like of shape (n_samples, n_features)
|
|
269
|
+
Feature matrix.
|
|
270
|
+
labels : array-like of shape (n_samples,)
|
|
271
|
+
Label vector (typically batch).
|
|
272
|
+
perplexity : float, default=30.0
|
|
273
|
+
Forwarded to :func:`lisi`.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
float
|
|
278
|
+
``lisi(X, labels) / lisi_max(labels)``, or ``nan`` when there
|
|
279
|
+
are fewer than two unique labels (normalisation is undefined).
|
|
280
|
+
"""
|
|
281
|
+
mx = lisi_max(labels)
|
|
282
|
+
if mx <= 1.0:
|
|
283
|
+
return float("nan")
|
|
284
|
+
return float(lisi(X, labels, perplexity=perplexity) / mx)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def species_preservation(
|
|
288
|
+
X: ArrayLike, species: ArrayLike, *, perplexity: float = 30.0
|
|
289
|
+
) -> float:
|
|
290
|
+
r"""Score on ``[0, 1]`` where 1 = species perfectly preserved, 0 = blurred.
|
|
291
|
+
|
|
292
|
+
The ideal post-correction LISI for *species* (or any biological
|
|
293
|
+
grouping you want to keep apart) is :math:`1` -- each
|
|
294
|
+
neighbourhood is dominated by one species. The *worst* value under
|
|
295
|
+
perfect mixing is :math:`\text{lisi}_{\max}(\text{species})`. We
|
|
296
|
+
rescale the gap to ``[0, 1]``:
|
|
297
|
+
|
|
298
|
+
.. math::
|
|
299
|
+
|
|
300
|
+
\text{species\_preservation}
|
|
301
|
+
= \frac{\text{lisi}_{\max} - \text{lisi}(X, \text{species})}
|
|
302
|
+
{\text{lisi}_{\max} - 1}
|
|
303
|
+
|
|
304
|
+
so the metric reads naturally as "fraction of biological structure
|
|
305
|
+
retained" and is invariant to species-imbalance (unlike raw LISI).
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
X : array-like of shape (n_samples, n_features)
|
|
310
|
+
Feature matrix.
|
|
311
|
+
species : array-like of shape (n_samples,)
|
|
312
|
+
Species (or other biology) labels.
|
|
313
|
+
perplexity : float, default=30.0
|
|
314
|
+
Forwarded to :func:`lisi`.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
float
|
|
319
|
+
Preservation score in ``[0, 1]`` (``1.0`` = perfectly preserved,
|
|
320
|
+
``0.0`` = blurred at the imbalance ceiling). Returns ``nan``
|
|
321
|
+
when fewer than two unique species are present.
|
|
322
|
+
"""
|
|
323
|
+
mx = lisi_max(species)
|
|
324
|
+
if mx <= 1.0:
|
|
325
|
+
return float("nan")
|
|
326
|
+
raw = lisi(X, species, perplexity=perplexity)
|
|
327
|
+
return float((mx - raw) / (mx - 1.0))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""MALDI-specific batch diagnostics.
|
|
2
|
+
|
|
3
|
+
These functions operate directly on binned-intensity matrices (rows =
|
|
4
|
+
samples, columns = m/z bins) and produce tidy per-batch summaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import numpy.typing as npt
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .._utils import ArrayLike, _resolve_mz_axis
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"peak_position_drift",
|
|
19
|
+
"per_batch_spectrum_count",
|
|
20
|
+
"tic_cov_per_batch",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_dataframe(X: ArrayLike) -> pd.DataFrame:
|
|
25
|
+
if isinstance(X, pd.DataFrame):
|
|
26
|
+
return X
|
|
27
|
+
arr = np.asarray(X)
|
|
28
|
+
return pd.DataFrame(arr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def per_batch_spectrum_count(batch: ArrayLike) -> pd.Series:
|
|
32
|
+
"""Return the number of spectra per batch as a sorted Series."""
|
|
33
|
+
s = pd.Series(np.asarray(batch)).value_counts().sort_index()
|
|
34
|
+
s.name = "n_spectra"
|
|
35
|
+
s.index.name = "batch"
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tic_cov_per_batch(X: ArrayLike, batch: ArrayLike) -> pd.Series:
|
|
40
|
+
"""Per-batch coefficient of variation of the Total Ion Count.
|
|
41
|
+
|
|
42
|
+
High CoV within a batch suggests substantial within-batch intensity
|
|
43
|
+
fluctuation, which in MALDI-TOF tends to be driven by acquisition
|
|
44
|
+
issues rather than biology.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
X : array-like of shape (n_samples, n_features)
|
|
49
|
+
Binned intensities.
|
|
50
|
+
batch : array-like of shape (n_samples,)
|
|
51
|
+
Batch labels.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
pd.Series
|
|
56
|
+
CoV (``std/mean``) of TIC for each batch, indexed by batch level.
|
|
57
|
+
"""
|
|
58
|
+
df = _to_dataframe(X)
|
|
59
|
+
tic = df.sum(axis=1).to_numpy()
|
|
60
|
+
b = np.asarray(batch)
|
|
61
|
+
out: dict[Any, float] = {}
|
|
62
|
+
for lvl in np.unique(b):
|
|
63
|
+
vals = tic[b == lvl]
|
|
64
|
+
mean = float(vals.mean())
|
|
65
|
+
std = float(vals.std(ddof=0))
|
|
66
|
+
out[lvl] = std / mean if mean > 0 else float("nan")
|
|
67
|
+
s = pd.Series(out).sort_index()
|
|
68
|
+
s.name = "tic_cov"
|
|
69
|
+
s.index.name = "batch"
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def peak_position_drift(
|
|
74
|
+
X: ArrayLike,
|
|
75
|
+
batch: ArrayLike,
|
|
76
|
+
*,
|
|
77
|
+
mz_values: ArrayLike | None = None,
|
|
78
|
+
top_k: int = 50,
|
|
79
|
+
) -> pd.DataFrame:
|
|
80
|
+
"""Per-batch peak-position drift relative to a global reference.
|
|
81
|
+
|
|
82
|
+
For each batch we compute its median spectrum, identify the top
|
|
83
|
+
``top_k`` peaks of the **global** median spectrum, and locate the
|
|
84
|
+
nearest local maximum of the per-batch median to each of those
|
|
85
|
+
global peaks. The returned table summarises the distribution of
|
|
86
|
+
(signed) position shifts per batch.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
X : array-like of shape (n_samples, n_features)
|
|
91
|
+
Binned intensities.
|
|
92
|
+
batch : array-like of shape (n_samples,)
|
|
93
|
+
Batch labels.
|
|
94
|
+
mz_values : array-like of shape (n_features,), optional
|
|
95
|
+
m/z values for the columns of ``X``. If None, integer column
|
|
96
|
+
positions are used, and the returned ``mean_delta_mz`` column is
|
|
97
|
+
in bin units.
|
|
98
|
+
top_k : int, default=50
|
|
99
|
+
Number of global peaks to track.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
pd.DataFrame
|
|
104
|
+
One row per batch, with columns
|
|
105
|
+
``mean_delta_mz``, ``median_delta_mz``, ``max_abs_delta_mz``.
|
|
106
|
+
"""
|
|
107
|
+
df = _to_dataframe(X).astype(float)
|
|
108
|
+
b = np.asarray(batch)
|
|
109
|
+
mz, _ = _resolve_mz_axis(df, mz_values)
|
|
110
|
+
|
|
111
|
+
global_median = df.median(axis=0).to_numpy()
|
|
112
|
+
# Local maxima of the global median
|
|
113
|
+
peaks_idx = _local_maxima(global_median)
|
|
114
|
+
if peaks_idx.size == 0:
|
|
115
|
+
# Degenerate case: flat / monotonic spectrum
|
|
116
|
+
return pd.DataFrame(
|
|
117
|
+
{
|
|
118
|
+
"mean_delta_mz": [],
|
|
119
|
+
"median_delta_mz": [],
|
|
120
|
+
"max_abs_delta_mz": [],
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
peak_intensities = global_median[peaks_idx]
|
|
124
|
+
order = np.argsort(-peak_intensities)[:top_k]
|
|
125
|
+
tracked = peaks_idx[order]
|
|
126
|
+
|
|
127
|
+
records = []
|
|
128
|
+
for lvl in np.unique(b):
|
|
129
|
+
batch_median = df.loc[b == lvl].median(axis=0).to_numpy()
|
|
130
|
+
batch_peaks = _local_maxima(batch_median)
|
|
131
|
+
if batch_peaks.size == 0:
|
|
132
|
+
records.append(
|
|
133
|
+
{
|
|
134
|
+
"batch": lvl,
|
|
135
|
+
"mean_delta_mz": float("nan"),
|
|
136
|
+
"median_delta_mz": float("nan"),
|
|
137
|
+
"max_abs_delta_mz": float("nan"),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
continue
|
|
141
|
+
deltas = []
|
|
142
|
+
for p in tracked:
|
|
143
|
+
nearest = batch_peaks[int(np.argmin(np.abs(batch_peaks - p)))]
|
|
144
|
+
deltas.append(mz[nearest] - mz[p])
|
|
145
|
+
deltas_arr = np.asarray(deltas)
|
|
146
|
+
records.append(
|
|
147
|
+
{
|
|
148
|
+
"batch": lvl,
|
|
149
|
+
"mean_delta_mz": float(deltas_arr.mean()),
|
|
150
|
+
"median_delta_mz": float(np.median(deltas_arr)),
|
|
151
|
+
"max_abs_delta_mz": float(np.max(np.abs(deltas_arr))),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
out = pd.DataFrame(records).set_index("batch")
|
|
155
|
+
out = out.sort_index()
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _local_maxima(x: npt.NDArray[np.float64]) -> npt.NDArray[np.int64]:
|
|
160
|
+
"""Return indices of strict local maxima of a 1-D array."""
|
|
161
|
+
if x.size < 3:
|
|
162
|
+
return np.asarray([], dtype=np.int64)
|
|
163
|
+
greater_prev = x[1:-1] > x[:-2]
|
|
164
|
+
greater_next = x[1:-1] > x[2:]
|
|
165
|
+
return np.where(greater_prev & greater_next)[0] + 1
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Combined before/after diagnostic report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .._utils import ArrayLike
|
|
8
|
+
from .generic import kbet, lisi, silhouette_batch
|
|
9
|
+
from .maldi import peak_position_drift, per_batch_spectrum_count, tic_cov_per_batch
|
|
10
|
+
|
|
11
|
+
__all__ = ["diagnostic_report"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def diagnostic_report(
|
|
15
|
+
before: ArrayLike,
|
|
16
|
+
after: ArrayLike,
|
|
17
|
+
batch: ArrayLike,
|
|
18
|
+
*,
|
|
19
|
+
mz_values: ArrayLike | None = None,
|
|
20
|
+
k: int | None = None,
|
|
21
|
+
lisi_perplexity: float = 30.0,
|
|
22
|
+
top_k_peaks: int = 50,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
"""Run every diagnostic on a (before, after) pair.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
before : array-like of shape (n_samples, n_features)
|
|
29
|
+
Feature matrix prior to batch correction.
|
|
30
|
+
after : array-like of shape (n_samples, n_features)
|
|
31
|
+
Feature matrix after batch correction. Must have the same shape
|
|
32
|
+
as ``before``.
|
|
33
|
+
batch : array-like of shape (n_samples,)
|
|
34
|
+
Batch labels.
|
|
35
|
+
mz_values : array-like, optional
|
|
36
|
+
m/z positions for the feature columns (passed to
|
|
37
|
+
:func:`peak_position_drift`).
|
|
38
|
+
k : int, optional
|
|
39
|
+
Neighbourhood size for kBET.
|
|
40
|
+
lisi_perplexity : float, default=30.0
|
|
41
|
+
Perplexity for LISI.
|
|
42
|
+
top_k_peaks : int, default=50
|
|
43
|
+
Number of peaks tracked for drift.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
pd.DataFrame
|
|
48
|
+
Tidy report with columns ``metric``, ``scope``, ``value_before``,
|
|
49
|
+
``value_after`` (and ``delta`` where both columns are numeric and
|
|
50
|
+
the metric's improvement direction is well-defined).
|
|
51
|
+
"""
|
|
52
|
+
rows: list[dict] = []
|
|
53
|
+
|
|
54
|
+
# Generic batch mixing (smaller = better for silhouette / chi2)
|
|
55
|
+
sil_b = silhouette_batch(before, batch)
|
|
56
|
+
sil_a = silhouette_batch(after, batch)
|
|
57
|
+
rows.append(
|
|
58
|
+
{
|
|
59
|
+
"metric": "silhouette_batch",
|
|
60
|
+
"scope": "overall",
|
|
61
|
+
"value_before": sil_b,
|
|
62
|
+
"value_after": sil_a,
|
|
63
|
+
"delta": sil_a - sil_b,
|
|
64
|
+
"better": "lower",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
kbet_b = kbet(before, batch, k=k)
|
|
69
|
+
kbet_a = kbet(after, batch, k=k)
|
|
70
|
+
rows.append(
|
|
71
|
+
{
|
|
72
|
+
"metric": "kbet_acceptance_rate",
|
|
73
|
+
"scope": "overall",
|
|
74
|
+
"value_before": kbet_b["acceptance_rate"],
|
|
75
|
+
"value_after": kbet_a["acceptance_rate"],
|
|
76
|
+
"delta": kbet_a["acceptance_rate"] - kbet_b["acceptance_rate"],
|
|
77
|
+
"better": "higher",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
rows.append(
|
|
81
|
+
{
|
|
82
|
+
"metric": "kbet_mean_chi2",
|
|
83
|
+
"scope": "overall",
|
|
84
|
+
"value_before": kbet_b["mean_chi2"],
|
|
85
|
+
"value_after": kbet_a["mean_chi2"],
|
|
86
|
+
"delta": kbet_a["mean_chi2"] - kbet_b["mean_chi2"],
|
|
87
|
+
"better": "lower",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
lisi_b = lisi(before, batch, perplexity=lisi_perplexity)
|
|
92
|
+
lisi_a = lisi(after, batch, perplexity=lisi_perplexity)
|
|
93
|
+
rows.append(
|
|
94
|
+
{
|
|
95
|
+
"metric": "lisi",
|
|
96
|
+
"scope": "overall",
|
|
97
|
+
"value_before": lisi_b,
|
|
98
|
+
"value_after": lisi_a,
|
|
99
|
+
"delta": lisi_a - lisi_b,
|
|
100
|
+
"better": "higher",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# TIC CoV per batch
|
|
105
|
+
tic_b = tic_cov_per_batch(before, batch)
|
|
106
|
+
tic_a = tic_cov_per_batch(after, batch)
|
|
107
|
+
for lvl in tic_b.index:
|
|
108
|
+
rows.append(
|
|
109
|
+
{
|
|
110
|
+
"metric": "tic_cov",
|
|
111
|
+
"scope": str(lvl),
|
|
112
|
+
"value_before": float(tic_b.loc[lvl]),
|
|
113
|
+
"value_after": float(tic_a.loc[lvl]),
|
|
114
|
+
"delta": float(tic_a.loc[lvl] - tic_b.loc[lvl]),
|
|
115
|
+
"better": "lower",
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Peak-position drift per batch (mean |delta_mz|)
|
|
120
|
+
drift_b = peak_position_drift(before, batch, mz_values=mz_values, top_k=top_k_peaks)
|
|
121
|
+
drift_a = peak_position_drift(after, batch, mz_values=mz_values, top_k=top_k_peaks)
|
|
122
|
+
for lvl in drift_b.index:
|
|
123
|
+
rows.append(
|
|
124
|
+
{
|
|
125
|
+
"metric": "peak_drift_mean",
|
|
126
|
+
"scope": str(lvl),
|
|
127
|
+
"value_before": float(drift_b.loc[lvl, "mean_delta_mz"]),
|
|
128
|
+
"value_after": float(drift_a.loc[lvl, "mean_delta_mz"]),
|
|
129
|
+
"delta": float(
|
|
130
|
+
drift_a.loc[lvl, "mean_delta_mz"]
|
|
131
|
+
- drift_b.loc[lvl, "mean_delta_mz"]
|
|
132
|
+
),
|
|
133
|
+
"better": "zero",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Spectrum counts (unchanged by correction; still useful in reports)
|
|
138
|
+
counts = per_batch_spectrum_count(batch)
|
|
139
|
+
for lvl, n in counts.items():
|
|
140
|
+
rows.append(
|
|
141
|
+
{
|
|
142
|
+
"metric": "n_spectra",
|
|
143
|
+
"scope": str(lvl),
|
|
144
|
+
"value_before": int(n),
|
|
145
|
+
"value_after": int(n),
|
|
146
|
+
"delta": 0,
|
|
147
|
+
"better": "n/a",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return pd.DataFrame(rows)
|