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,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)
@@ -0,0 +1,9 @@
1
+ """Integrations with external MALDI toolkits.
2
+
3
+ Currently exposes :class:`MaldiSetAdapter`, a bridge to
4
+ :class:`maldiamrkit.MaldiSet`.
5
+ """
6
+
7
+ from .maldiset import MaldiSetAdapter
8
+
9
+ __all__ = ["MaldiSetAdapter"]