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/cli.py ADDED
@@ -0,0 +1,497 @@
1
+ """Command-line interface for MaldiBatchKit.
2
+
3
+ The CLI is organised as::
4
+
5
+ maldibatchkit
6
+ +-- correct
7
+ | +-- combat (Johnson 2007)
8
+ | +-- combat-fortin (Fortin 2018, covariate-aware)
9
+ | +-- combat-chen (Chen 2022, CovBat)
10
+ | +-- species-combat (Fortin preset with species)
11
+ | +-- quality-combat (weighted EB extension)
12
+ | +-- limma (Ritchie 2015)
13
+ | +-- harmony (Korsunsky 2019)
14
+ | +-- median-center
15
+ | +-- zscore-per-batch
16
+ | +-- reference-scaling
17
+ | +-- warping (BatchAwareWarping)
18
+ +-- diagnose
19
+
20
+ Every ``correct`` subcommand shares the same ``--input / --output /
21
+ --batch-csv`` contract and adds the flags that are meaningful for its
22
+ method (covariates, reference batch, parametric toggles, ...). Use
23
+ ``maldibatchkit correct <method> --help`` to see the exact options for
24
+ a given corrector.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from typing import Annotated, Optional
31
+
32
+ import numpy as np
33
+ import pandas as pd
34
+
35
+ try:
36
+ import typer
37
+ except ImportError as _typer_err: # pragma: no cover - typer is a hard dep
38
+ raise ImportError(
39
+ "The MaldiBatchKit CLI depends on `typer`. "
40
+ "Install it with `pip install typer[all]`."
41
+ ) from _typer_err
42
+
43
+ from . import (
44
+ BatchAwareWarping,
45
+ ComBat,
46
+ Harmony,
47
+ Limma,
48
+ MedianCentering,
49
+ QualityWeightedComBat,
50
+ ReferenceScaling,
51
+ SpeciesAwareComBat,
52
+ ZScorePerBatch,
53
+ )
54
+ from ._cli_io import (
55
+ load_dataframe_csv,
56
+ load_labels_csv,
57
+ load_matrix,
58
+ load_mz_csv,
59
+ save_matrix,
60
+ )
61
+ from .diagnostics import diagnostic_report
62
+
63
+ app = typer.Typer(
64
+ name="maldibatchkit",
65
+ help="Batch-effect correction for MALDI-TOF AMR feature matrices.",
66
+ add_completion=False,
67
+ no_args_is_help=True,
68
+ )
69
+
70
+ correct_app = typer.Typer(
71
+ name="correct",
72
+ help="Apply a batch-correction method to a feature matrix.",
73
+ add_completion=False,
74
+ no_args_is_help=True,
75
+ )
76
+ app.add_typer(correct_app)
77
+
78
+
79
+ InputOpt = Annotated[
80
+ Path, typer.Option("-i", "--input", help="Feature matrix (CSV or NPZ).")
81
+ ]
82
+ OutputOpt = Annotated[
83
+ Path, typer.Option("-o", "--output", help="Output path (CSV or NPZ).")
84
+ ]
85
+ BatchOpt = Annotated[
86
+ Optional[Path],
87
+ typer.Option(
88
+ "--batch-csv",
89
+ help="Single-column CSV of batch labels (index = sample IDs).",
90
+ ),
91
+ ]
92
+
93
+
94
+ def _run_and_save(
95
+ transformer, X: pd.DataFrame, output: Path, batch: pd.Series, extras: dict
96
+ ) -> None:
97
+ """Fit-transform + save, preserving the input container shape."""
98
+ X_corrected = transformer.fit_transform(X)
99
+ if not isinstance(X_corrected, pd.DataFrame):
100
+ X_corrected = pd.DataFrame(
101
+ np.asarray(X_corrected), index=X.index, columns=X.columns
102
+ )
103
+ save_matrix(X_corrected, output, batch=batch, extras=extras)
104
+ typer.echo(f"wrote {output} ({X_corrected.shape[0]} x {X_corrected.shape[1]})")
105
+
106
+
107
+ def _load_covariates(
108
+ discrete_csv: Path | None,
109
+ continuous_csv: Path | None,
110
+ index: pd.Index,
111
+ ) -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
112
+ discrete = load_dataframe_csv(discrete_csv, index) if discrete_csv else None
113
+ continuous = load_dataframe_csv(continuous_csv, index) if continuous_csv else None
114
+ return discrete, continuous
115
+
116
+
117
+ @correct_app.command("combat")
118
+ def correct_combat(
119
+ input: InputOpt,
120
+ output: OutputOpt,
121
+ batch_csv: BatchOpt = None,
122
+ parametric: Annotated[
123
+ bool, typer.Option(help="Use parametric empirical Bayes.")
124
+ ] = True,
125
+ mean_only: Annotated[
126
+ bool, typer.Option(help="Adjust only the mean, not the variance.")
127
+ ] = False,
128
+ reference_batch: Annotated[
129
+ Optional[str],
130
+ typer.Option(help="Batch level to leave unchanged."),
131
+ ] = None,
132
+ eps: Annotated[float, typer.Option(help="Numerical jitter.")] = 1e-8,
133
+ ) -> None:
134
+ """Vanilla Johnson (2007) ComBat. No covariates."""
135
+ X, batch, extras = load_matrix(input, batch_csv)
136
+ transformer = ComBat(
137
+ batch=batch,
138
+ method="johnson",
139
+ parametric=parametric,
140
+ mean_only=mean_only,
141
+ reference_batch=reference_batch,
142
+ eps=eps,
143
+ )
144
+ _run_and_save(transformer, X, output, batch, extras)
145
+
146
+
147
+ @correct_app.command("combat-fortin")
148
+ def correct_combat_fortin(
149
+ input: InputOpt,
150
+ output: OutputOpt,
151
+ batch_csv: BatchOpt = None,
152
+ discrete_covariates_csv: Annotated[
153
+ Optional[Path],
154
+ typer.Option(
155
+ "--discrete-covariates-csv",
156
+ help="CSV of categorical covariates to protect "
157
+ "(index = sample IDs, any number of columns).",
158
+ ),
159
+ ] = None,
160
+ continuous_covariates_csv: Annotated[
161
+ Optional[Path],
162
+ typer.Option(
163
+ "--continuous-covariates-csv",
164
+ help="CSV of continuous covariates to protect.",
165
+ ),
166
+ ] = None,
167
+ parametric: Annotated[bool, typer.Option()] = True,
168
+ mean_only: Annotated[bool, typer.Option()] = False,
169
+ reference_batch: Annotated[Optional[str], typer.Option()] = None,
170
+ eps: Annotated[float, typer.Option()] = 1e-8,
171
+ ) -> None:
172
+ """Fortin (2018) covariate-aware ComBat."""
173
+ X, batch, extras = load_matrix(input, batch_csv)
174
+ discrete, continuous = _load_covariates(
175
+ discrete_covariates_csv, continuous_covariates_csv, X.index
176
+ )
177
+ if discrete is None and continuous is None:
178
+ raise typer.BadParameter(
179
+ "combat-fortin requires at least one of "
180
+ "--discrete-covariates-csv / --continuous-covariates-csv. "
181
+ "Without covariates this reduces to Johnson; use "
182
+ "`maldibatchkit correct combat` instead."
183
+ )
184
+ transformer = ComBat(
185
+ batch=batch,
186
+ discrete_covariates=discrete,
187
+ continuous_covariates=continuous,
188
+ method="fortin",
189
+ parametric=parametric,
190
+ mean_only=mean_only,
191
+ reference_batch=reference_batch,
192
+ eps=eps,
193
+ )
194
+ _run_and_save(transformer, X, output, batch, extras)
195
+
196
+
197
+ @correct_app.command("combat-chen")
198
+ def correct_combat_chen(
199
+ input: InputOpt,
200
+ output: OutputOpt,
201
+ batch_csv: BatchOpt = None,
202
+ discrete_covariates_csv: Annotated[Optional[Path], typer.Option()] = None,
203
+ continuous_covariates_csv: Annotated[Optional[Path], typer.Option()] = None,
204
+ covbat_cov_thresh: Annotated[
205
+ float,
206
+ typer.Option(
207
+ help="Variance threshold in (0, 1] for PCs, or int >= 1 for a "
208
+ "fixed number of components."
209
+ ),
210
+ ] = 0.9,
211
+ parametric: Annotated[bool, typer.Option()] = True,
212
+ mean_only: Annotated[bool, typer.Option()] = False,
213
+ reference_batch: Annotated[Optional[str], typer.Option()] = None,
214
+ eps: Annotated[float, typer.Option()] = 1e-8,
215
+ ) -> None:
216
+ """Chen (2022) CovBat: ComBat on PCA-decomposed batch covariance."""
217
+ X, batch, extras = load_matrix(input, batch_csv)
218
+ discrete, continuous = _load_covariates(
219
+ discrete_covariates_csv, continuous_covariates_csv, X.index
220
+ )
221
+ transformer = ComBat(
222
+ batch=batch,
223
+ discrete_covariates=discrete,
224
+ continuous_covariates=continuous,
225
+ method="chen",
226
+ parametric=parametric,
227
+ mean_only=mean_only,
228
+ reference_batch=reference_batch,
229
+ eps=eps,
230
+ covbat_cov_thresh=covbat_cov_thresh,
231
+ )
232
+ _run_and_save(transformer, X, output, batch, extras)
233
+
234
+
235
+ @correct_app.command("species-combat")
236
+ def correct_species_combat(
237
+ input: InputOpt,
238
+ output: OutputOpt,
239
+ batch_csv: BatchOpt = None,
240
+ species_csv: Annotated[
241
+ Optional[Path],
242
+ typer.Option(
243
+ "--species-csv",
244
+ help="Single-column CSV with species labels "
245
+ "(index = sample IDs). Required.",
246
+ ),
247
+ ] = None,
248
+ continuous_covariates_csv: Annotated[Optional[Path], typer.Option()] = None,
249
+ parametric: Annotated[bool, typer.Option()] = True,
250
+ mean_only: Annotated[bool, typer.Option()] = False,
251
+ reference_batch: Annotated[Optional[str], typer.Option()] = None,
252
+ eps: Annotated[float, typer.Option()] = 1e-8,
253
+ ) -> None:
254
+ """Run ComBat-Fortin with species as the protected covariate."""
255
+ X, batch, extras = load_matrix(input, batch_csv)
256
+ if species_csv is None:
257
+ raise typer.BadParameter("species-combat requires --species-csv.")
258
+ species = load_labels_csv(species_csv, X.index)
259
+ continuous = (
260
+ load_dataframe_csv(continuous_covariates_csv, X.index)
261
+ if continuous_covariates_csv
262
+ else None
263
+ )
264
+ transformer = SpeciesAwareComBat(
265
+ batch=batch,
266
+ species=species,
267
+ continuous_covariates=continuous,
268
+ parametric=parametric,
269
+ mean_only=mean_only,
270
+ reference_batch=reference_batch,
271
+ eps=eps,
272
+ )
273
+ _run_and_save(transformer, X, output, batch, extras)
274
+
275
+
276
+ @correct_app.command("quality-combat")
277
+ def correct_quality_combat(
278
+ input: InputOpt,
279
+ output: OutputOpt,
280
+ batch_csv: BatchOpt = None,
281
+ quality_csv: Annotated[
282
+ Optional[Path],
283
+ typer.Option(
284
+ "--quality-csv",
285
+ help="Single-column CSV with non-negative per-sample weights "
286
+ "(typically SNR). Required.",
287
+ ),
288
+ ] = None,
289
+ parametric: Annotated[bool, typer.Option()] = True,
290
+ reference_batch: Annotated[Optional[str], typer.Option()] = None,
291
+ max_iter: Annotated[int, typer.Option(help="Parametric EB iteration cap.")] = 50,
292
+ tol: Annotated[float, typer.Option(help="Convergence tolerance.")] = 1e-4,
293
+ eps: Annotated[float, typer.Option()] = 1e-8,
294
+ ) -> None:
295
+ """Quality-weighted empirical-Bayes ComBat."""
296
+ X, batch, extras = load_matrix(input, batch_csv)
297
+ if quality_csv is None:
298
+ raise typer.BadParameter("quality-combat requires --quality-csv.")
299
+ quality = load_labels_csv(quality_csv, X.index)
300
+ transformer = QualityWeightedComBat(
301
+ batch=batch,
302
+ quality=quality,
303
+ parametric=parametric,
304
+ reference_batch=reference_batch,
305
+ eps=eps,
306
+ max_iter=max_iter,
307
+ tol=tol,
308
+ )
309
+ _run_and_save(transformer, X, output, batch, extras)
310
+
311
+
312
+ @correct_app.command("limma")
313
+ def correct_limma(
314
+ input: InputOpt,
315
+ output: OutputOpt,
316
+ batch_csv: BatchOpt = None,
317
+ design_csv: Annotated[
318
+ Optional[Path],
319
+ typer.Option(
320
+ "--design-csv",
321
+ help="CSV with covariates to protect (index = sample IDs, "
322
+ "any number of numeric columns).",
323
+ ),
324
+ ] = None,
325
+ eps: Annotated[float, typer.Option(help="Ridge regularisation.")] = 1e-8,
326
+ ) -> None:
327
+ """limma::removeBatchEffect (Ritchie 2015)."""
328
+ X, batch, extras = load_matrix(input, batch_csv)
329
+ design = load_dataframe_csv(design_csv, X.index) if design_csv else None
330
+ transformer = Limma(batch=batch, design=design, eps=eps)
331
+ _run_and_save(transformer, X, output, batch, extras)
332
+
333
+
334
+ @correct_app.command("harmony")
335
+ def correct_harmony(
336
+ input: InputOpt,
337
+ output: OutputOpt,
338
+ batch_csv: BatchOpt = None,
339
+ covariates_csv: Annotated[
340
+ Optional[Path],
341
+ typer.Option(
342
+ "--covariates-csv",
343
+ help="Extra categorical covariates forwarded to harmonypy "
344
+ "vars_use (index = sample IDs).",
345
+ ),
346
+ ] = None,
347
+ theta: Annotated[float, typer.Option(help="Diversity clustering penalty.")] = 2.0,
348
+ max_iter: Annotated[int, typer.Option(help="Harmony iterations.")] = 20,
349
+ random_state: Annotated[Optional[int], typer.Option()] = None,
350
+ ) -> None:
351
+ """Harmony (Korsunsky 2019) via harmonypy. Train/test safe via closed-form transform."""
352
+ X, batch, extras = load_matrix(input, batch_csv)
353
+ covariates = load_dataframe_csv(covariates_csv, X.index) if covariates_csv else None
354
+ transformer = Harmony(
355
+ batch=batch,
356
+ covariates=covariates,
357
+ theta=theta,
358
+ max_iter=max_iter,
359
+ random_state=random_state,
360
+ )
361
+ _run_and_save(transformer, X, output, batch, extras)
362
+
363
+
364
+ @correct_app.command("median-center")
365
+ def correct_median_center(
366
+ input: InputOpt,
367
+ output: OutputOpt,
368
+ batch_csv: BatchOpt = None,
369
+ ) -> None:
370
+ """Per-batch median centering."""
371
+ X, batch, extras = load_matrix(input, batch_csv)
372
+ transformer = MedianCentering(batch=batch)
373
+ _run_and_save(transformer, X, output, batch, extras)
374
+
375
+
376
+ @correct_app.command("zscore-per-batch")
377
+ def correct_zscore_per_batch(
378
+ input: InputOpt,
379
+ output: OutputOpt,
380
+ batch_csv: BatchOpt = None,
381
+ eps: Annotated[float, typer.Option()] = 1e-8,
382
+ ) -> None:
383
+ """Per-batch z-score normalisation."""
384
+ X, batch, extras = load_matrix(input, batch_csv)
385
+ transformer = ZScorePerBatch(batch=batch, eps=eps)
386
+ _run_and_save(transformer, X, output, batch, extras)
387
+
388
+
389
+ @correct_app.command("reference-scaling")
390
+ def correct_reference_scaling(
391
+ input: InputOpt,
392
+ output: OutputOpt,
393
+ batch_csv: BatchOpt = None,
394
+ reference_batch: Annotated[
395
+ Optional[str],
396
+ typer.Option(
397
+ help="Batch level to use as reference. Defaults to the "
398
+ "largest training batch."
399
+ ),
400
+ ] = None,
401
+ eps: Annotated[float, typer.Option()] = 1e-8,
402
+ ) -> None:
403
+ """Multiplicative per-batch scaling to a reference."""
404
+ X, batch, extras = load_matrix(input, batch_csv)
405
+ transformer = ReferenceScaling(
406
+ batch=batch, reference_batch=reference_batch, eps=eps
407
+ )
408
+ _run_and_save(transformer, X, output, batch, extras)
409
+
410
+
411
+ @correct_app.command("warping")
412
+ def correct_warping(
413
+ input: InputOpt,
414
+ output: OutputOpt,
415
+ batch_csv: BatchOpt = None,
416
+ reference: Annotated[
417
+ str,
418
+ typer.Option(help="Global reference: 'median' or 'batch_mean'."),
419
+ ] = "median",
420
+ method: Annotated[
421
+ str,
422
+ typer.Option(help="Warping strategy: shift / linear / piecewise / dtw."),
423
+ ] = "shift",
424
+ n_segments: Annotated[int, typer.Option()] = 5,
425
+ max_shift: Annotated[int, typer.Option()] = 50,
426
+ dtw_radius: Annotated[int, typer.Option()] = 10,
427
+ smooth_sigma: Annotated[float, typer.Option()] = 2.0,
428
+ n_jobs: Annotated[int, typer.Option()] = 1,
429
+ ) -> None:
430
+ """Batch-aware m/z warping onto a shared global reference."""
431
+ X, batch, extras = load_matrix(input, batch_csv)
432
+ transformer = BatchAwareWarping(
433
+ batch=batch,
434
+ reference=reference,
435
+ method=method,
436
+ n_segments=n_segments,
437
+ max_shift=max_shift,
438
+ dtw_radius=dtw_radius,
439
+ smooth_sigma=smooth_sigma,
440
+ n_jobs=n_jobs,
441
+ )
442
+ _run_and_save(transformer, X, output, batch, extras)
443
+
444
+
445
+ @app.command("diagnose")
446
+ def diagnose(
447
+ input: InputOpt,
448
+ output: OutputOpt,
449
+ batch_csv: BatchOpt = None,
450
+ corrected: Annotated[
451
+ Optional[Path],
452
+ typer.Option(
453
+ "--corrected",
454
+ help="Corrected matrix (CSV or NPZ). If omitted, the "
455
+ "report compares the input to itself.",
456
+ ),
457
+ ] = None,
458
+ mz_csv: Annotated[
459
+ Optional[Path],
460
+ typer.Option(
461
+ "--mz-csv",
462
+ help="m/z values (single-column CSV of length n_features) "
463
+ "used by the peak-drift metric.",
464
+ ),
465
+ ] = None,
466
+ top_k_peaks: Annotated[
467
+ int, typer.Option(help="Number of peaks tracked for drift.")
468
+ ] = 50,
469
+ lisi_perplexity: Annotated[float, typer.Option(help="Perplexity for LISI.")] = 30.0,
470
+ kbet_k: Annotated[
471
+ Optional[int],
472
+ typer.Option("--kbet-k", help="Neighbourhood size for kBET."),
473
+ ] = None,
474
+ ) -> None:
475
+ """Run the full diagnostic report and write a tidy CSV."""
476
+ before, batch, _ = load_matrix(input, batch_csv)
477
+ if corrected is not None:
478
+ after, _, _ = load_matrix(corrected, batch_csv)
479
+ else:
480
+ after = before.copy()
481
+ mz_values = load_mz_csv(mz_csv, before.shape[1]) if mz_csv else None
482
+ report = diagnostic_report(
483
+ before,
484
+ after,
485
+ batch,
486
+ mz_values=mz_values,
487
+ k=kbet_k,
488
+ lisi_perplexity=lisi_perplexity,
489
+ top_k_peaks=top_k_peaks,
490
+ )
491
+ output.parent.mkdir(parents=True, exist_ok=True)
492
+ report.to_csv(output, index=False)
493
+ typer.echo(f"wrote {output} ({len(report)} rows)")
494
+
495
+
496
+ if __name__ == "__main__": # pragma: no cover
497
+ app()
@@ -0,0 +1,20 @@
1
+ """Batch-effect correction transformers (all train/test safe)."""
2
+
3
+ from .baselines import MedianCentering, ReferenceScaling, ZScorePerBatch
4
+ from .combat import ComBat, SpeciesAwareComBat
5
+ from .harmony import Harmony
6
+ from .limma import Limma
7
+ from .maldi import BatchAwareWarping
8
+ from .quality_weighted import QualityWeightedComBat
9
+
10
+ __all__ = [
11
+ "BatchAwareWarping",
12
+ "ComBat",
13
+ "Harmony",
14
+ "Limma",
15
+ "MedianCentering",
16
+ "QualityWeightedComBat",
17
+ "ReferenceScaling",
18
+ "SpeciesAwareComBat",
19
+ "ZScorePerBatch",
20
+ ]