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,56 @@
|
|
|
1
|
+
"""MaldiBatchKit - batch-effect correction for MALDI-TOF AMR workflows.
|
|
2
|
+
|
|
3
|
+
Subpackage guide
|
|
4
|
+
----------------
|
|
5
|
+
- ``maldibatchkit.corrections`` - ComBat variants, Limma, Harmony,
|
|
6
|
+
simple baselines, and MALDI-specific corrections
|
|
7
|
+
(``BatchAwareWarping``, ``QualityWeightedComBat``,
|
|
8
|
+
``SpeciesAwareComBat``).
|
|
9
|
+
- ``maldibatchkit.diagnostics`` - generic batch-mixing metrics (kBET, LISI,
|
|
10
|
+
silhouette) and MALDI-specific metrics (peak drift, per-batch TIC CoV,
|
|
11
|
+
spectrum counts), plus a ``diagnostic_report`` helper.
|
|
12
|
+
- ``maldibatchkit.viz`` - ``plot_batch_umap``, ``plot_peak_shift``,
|
|
13
|
+
``plot_diagnostic_summary``.
|
|
14
|
+
- ``maldibatchkit.integrations`` - ``MaldiSetAdapter`` for seamless use with
|
|
15
|
+
``maldiamrkit.MaldiSet``.
|
|
16
|
+
|
|
17
|
+
Examples
|
|
18
|
+
--------
|
|
19
|
+
>>> from maldibatchkit import ComBat, QualityWeightedComBat
|
|
20
|
+
>>> from maldibatchkit.diagnostics import diagnostic_report
|
|
21
|
+
>>> from maldibatchkit.integrations import MaldiSetAdapter
|
|
22
|
+
|
|
23
|
+
Extending MaldiBatchKit: subclass :class:`BaseBatchCorrector` and implement
|
|
24
|
+
``_fit_impl`` / ``_transform_impl`` to ship a custom corrector that plugs
|
|
25
|
+
into sklearn pipelines without re-solving index alignment or leakage.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from ._base import BaseBatchCorrector
|
|
29
|
+
from .corrections.baselines import (
|
|
30
|
+
MedianCentering,
|
|
31
|
+
ReferenceScaling,
|
|
32
|
+
ZScorePerBatch,
|
|
33
|
+
)
|
|
34
|
+
from .corrections.combat import ComBat, SpeciesAwareComBat
|
|
35
|
+
from .corrections.harmony import Harmony
|
|
36
|
+
from .corrections.limma import Limma
|
|
37
|
+
from .corrections.maldi import BatchAwareWarping
|
|
38
|
+
from .corrections.quality_weighted import QualityWeightedComBat
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
__author__ = "Ettore Rocchi"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BaseBatchCorrector",
|
|
45
|
+
"BatchAwareWarping",
|
|
46
|
+
"ComBat",
|
|
47
|
+
"Harmony",
|
|
48
|
+
"Limma",
|
|
49
|
+
"MedianCentering",
|
|
50
|
+
"QualityWeightedComBat",
|
|
51
|
+
"ReferenceScaling",
|
|
52
|
+
"SpeciesAwareComBat",
|
|
53
|
+
"ZScorePerBatch",
|
|
54
|
+
"__author__",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
maldibatchkit/_base.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Shared scaffolding for MaldiBatchKit batch correctors.
|
|
2
|
+
|
|
3
|
+
Every corrector in this package stores ``batch`` (and any covariates) at
|
|
4
|
+
``__init__`` time and aligns the stored arrays to ``X.index`` at
|
|
5
|
+
``fit`` / ``transform``. This mirrors the ``combatlearn`` convention and
|
|
6
|
+
lets sklearn ``Pipeline`` / cross-validation pass only ``X`` around while
|
|
7
|
+
keeping train and test folds correctly labelled.
|
|
8
|
+
|
|
9
|
+
The contract implemented here avoids the two common data-leakage traps for
|
|
10
|
+
batch correction:
|
|
11
|
+
|
|
12
|
+
1. ``fit`` learns correction parameters **only** from the training rows it
|
|
13
|
+
actually receives; no statistic is derived from rows the user has
|
|
14
|
+
already held back as a test set.
|
|
15
|
+
2. ``transform`` never re-fits. Fitted attributes (those ending in ``_``)
|
|
16
|
+
are used verbatim on unseen rows, even when a new batch label appears
|
|
17
|
+
in the test set.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import numpy.typing as npt
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
28
|
+
from sklearn.utils.validation import check_array, check_is_fitted
|
|
29
|
+
|
|
30
|
+
from ._utils import ArrayLike, _ensure_index, _subset
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_array_finite(X: Any) -> None:
|
|
34
|
+
"""Run :func:`sklearn.utils.validation.check_array` with finite values.
|
|
35
|
+
|
|
36
|
+
The kwarg that controls this check was renamed from
|
|
37
|
+
``force_all_finite`` (sklearn < 1.6) to ``ensure_all_finite``
|
|
38
|
+
(sklearn >= 1.6). We support both transparently so MaldiBatchKit
|
|
39
|
+
installs cleanly on either.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
check_array(X, ensure_all_finite=True, dtype="numeric")
|
|
43
|
+
except TypeError:
|
|
44
|
+
check_array(X, force_all_finite=True, dtype="numeric")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseBatchCorrector(BaseEstimator, TransformerMixin): # type: ignore[misc]
|
|
48
|
+
"""Base class for batch correctors that store ``batch`` at construction.
|
|
49
|
+
|
|
50
|
+
Subclasses must implement :meth:`_fit_impl` and :meth:`_transform_impl`.
|
|
51
|
+
They should store fitted attributes as ``..._`` members so sklearn's
|
|
52
|
+
``check_is_fitted`` works out of the box.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
batch : array-like of shape (n_samples,)
|
|
57
|
+
Batch labels for each sample. Passed once at construction; the
|
|
58
|
+
correct subset is aligned to ``X.index`` on ``fit`` / ``transform``.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
Subclasses typically override ``__init__`` to add method-specific
|
|
63
|
+
hyperparameters but should always call ``super().__init__(batch=batch)``
|
|
64
|
+
and leave ``batch`` stored under ``self.batch`` so
|
|
65
|
+
:meth:`get_params` / :meth:`set_params` work.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, batch: ArrayLike) -> None:
|
|
69
|
+
self.batch = batch
|
|
70
|
+
|
|
71
|
+
def _validate_X(self, X: ArrayLike) -> None:
|
|
72
|
+
"""Validate the feature matrix."""
|
|
73
|
+
_check_array_finite(X)
|
|
74
|
+
|
|
75
|
+
def _prepare_fit(
|
|
76
|
+
self, X: ArrayLike
|
|
77
|
+
) -> tuple[pd.DataFrame, npt.NDArray[Any], pd.Index]:
|
|
78
|
+
"""Run common ``fit``-time bookkeeping.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
X_df : pd.DataFrame
|
|
83
|
+
``X`` as a DataFrame with the inferred / preserved index.
|
|
84
|
+
batch_arr : ndarray
|
|
85
|
+
Batch labels aligned to ``X.index``.
|
|
86
|
+
idx : pd.Index
|
|
87
|
+
The shared index used by ``X`` and ``batch``.
|
|
88
|
+
"""
|
|
89
|
+
self._validate_X(X)
|
|
90
|
+
idx = _ensure_index(X)
|
|
91
|
+
if isinstance(X, pd.DataFrame):
|
|
92
|
+
self.feature_names_in_ = np.asarray(X.columns, dtype=object)
|
|
93
|
+
X_df = X
|
|
94
|
+
else:
|
|
95
|
+
self.feature_names_in_ = np.asarray(
|
|
96
|
+
[f"x{i}" for i in range(np.asarray(X).shape[1])], dtype=object
|
|
97
|
+
)
|
|
98
|
+
X_df = pd.DataFrame(np.asarray(X), index=idx)
|
|
99
|
+
batch_vec = _subset(self.batch, idx)
|
|
100
|
+
if batch_vec is None:
|
|
101
|
+
raise ValueError("`batch` must not be None for a batch corrector.")
|
|
102
|
+
if isinstance(batch_vec, pd.DataFrame):
|
|
103
|
+
if batch_vec.shape[1] != 1:
|
|
104
|
+
raise ValueError(f"`batch` must be 1-D; got shape {batch_vec.shape}.")
|
|
105
|
+
batch_vec = batch_vec.iloc[:, 0]
|
|
106
|
+
batch_ser = pd.Series(batch_vec, index=idx)
|
|
107
|
+
if batch_ser.isna().any():
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"batch contains {int(batch_ser.isna().sum())} NaN value(s); "
|
|
110
|
+
f"all batch labels must be non-null."
|
|
111
|
+
)
|
|
112
|
+
return X_df, batch_ser.to_numpy(), idx
|
|
113
|
+
|
|
114
|
+
def _prepare_transform(
|
|
115
|
+
self, X: ArrayLike
|
|
116
|
+
) -> tuple[pd.DataFrame, npt.NDArray[Any], pd.Index, bool]:
|
|
117
|
+
"""Run common ``transform``-time bookkeeping."""
|
|
118
|
+
check_is_fitted(self)
|
|
119
|
+
self._validate_X(X)
|
|
120
|
+
idx = _ensure_index(X)
|
|
121
|
+
was_df = isinstance(X, pd.DataFrame)
|
|
122
|
+
X_df = X if was_df else pd.DataFrame(np.asarray(X), index=idx)
|
|
123
|
+
batch_vec = _subset(self.batch, idx)
|
|
124
|
+
if isinstance(batch_vec, pd.DataFrame):
|
|
125
|
+
batch_vec = batch_vec.iloc[:, 0]
|
|
126
|
+
batch_ser = pd.Series(batch_vec, index=idx)
|
|
127
|
+
return X_df, batch_ser.to_numpy(), idx, was_df
|
|
128
|
+
|
|
129
|
+
def _fit_impl(
|
|
130
|
+
self, X_df: pd.DataFrame, batch: npt.NDArray[Any]
|
|
131
|
+
) -> None: # pragma: no cover - abstract
|
|
132
|
+
raise NotImplementedError
|
|
133
|
+
|
|
134
|
+
def _transform_impl(
|
|
135
|
+
self, X_df: pd.DataFrame, batch: npt.NDArray[Any]
|
|
136
|
+
) -> pd.DataFrame: # pragma: no cover - abstract
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
|
|
139
|
+
def fit(self, X: ArrayLike, y: ArrayLike | None = None) -> BaseBatchCorrector:
|
|
140
|
+
"""Fit the corrector on the training rows supplied.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
X : array-like of shape (n_samples, n_features)
|
|
145
|
+
Training features.
|
|
146
|
+
y : None
|
|
147
|
+
Ignored. Present for sklearn API compatibility.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
self : BaseBatchCorrector
|
|
152
|
+
Fitted estimator.
|
|
153
|
+
"""
|
|
154
|
+
X_df, batch_arr, _ = self._prepare_fit(X)
|
|
155
|
+
self._fit_impl(X_df, batch_arr)
|
|
156
|
+
self.n_features_in_ = X_df.shape[1]
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def transform(self, X: ArrayLike) -> Any:
|
|
160
|
+
"""Apply the fitted correction to ``X``.
|
|
161
|
+
|
|
162
|
+
Returns a :class:`pandas.DataFrame` when the input was a DataFrame,
|
|
163
|
+
or a :class:`numpy.ndarray` otherwise.
|
|
164
|
+
"""
|
|
165
|
+
X_df, batch_arr, idx, was_df = self._prepare_transform(X)
|
|
166
|
+
X_corrected = self._transform_impl(X_df, batch_arr)
|
|
167
|
+
X_corrected.index = idx
|
|
168
|
+
X_corrected.columns = X_df.columns
|
|
169
|
+
return X_corrected if was_df else X_corrected.to_numpy()
|
|
170
|
+
|
|
171
|
+
def get_feature_names_out(
|
|
172
|
+
self, input_features: ArrayLike | None = None
|
|
173
|
+
) -> npt.NDArray[Any]:
|
|
174
|
+
"""Return the feature names seen during :meth:`fit`."""
|
|
175
|
+
check_is_fitted(self, "feature_names_in_")
|
|
176
|
+
return self.feature_names_in_
|
maldibatchkit/_cli_io.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Shared I/O helpers for the MaldiBatchKit CLI.
|
|
2
|
+
|
|
3
|
+
Every ``correct`` subcommand and ``diagnose`` goes through this module so
|
|
4
|
+
the file-format contract is documented once.
|
|
5
|
+
|
|
6
|
+
Supported input / output containers
|
|
7
|
+
-----------------------------------
|
|
8
|
+
* **CSV**: first column is treated as the sample index; remaining
|
|
9
|
+
columns are features. Labels (batch, species, quality, covariates)
|
|
10
|
+
live in sidecar CSVs with a sample-id index column and one or more
|
|
11
|
+
data columns.
|
|
12
|
+
* **NPZ**: a ``np.savez`` archive containing at least ``X``; optionally
|
|
13
|
+
``columns``, ``index``, ``batch``, and arbitrary extra arrays kept as
|
|
14
|
+
passthrough metadata. This is the MaldiSet-friendly format.
|
|
15
|
+
|
|
16
|
+
All auxiliary loaders align the loaded object to ``X.index`` via
|
|
17
|
+
``.reindex`` when an index is available in the CSV, or positionally
|
|
18
|
+
otherwise.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"load_matrix",
|
|
31
|
+
"load_dataframe_csv",
|
|
32
|
+
"load_labels_csv",
|
|
33
|
+
"load_mz_csv",
|
|
34
|
+
"save_matrix",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_csv_indexed(path: Path) -> pd.DataFrame:
|
|
39
|
+
"""Read a CSV assuming the first column is the sample index."""
|
|
40
|
+
return pd.read_csv(path, index_col=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_matrix(
|
|
44
|
+
path: Path, batch_csv: Path | None
|
|
45
|
+
) -> tuple[pd.DataFrame, pd.Series, dict[str, np.ndarray]]:
|
|
46
|
+
"""Load a feature matrix and its aligned batch labels.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
path : Path
|
|
51
|
+
CSV or NPZ file. CSV first column is the sample index. NPZ must
|
|
52
|
+
contain an ``X`` array and may also contain ``columns``,
|
|
53
|
+
``index`` and ``batch`` arrays.
|
|
54
|
+
batch_csv : Path or None
|
|
55
|
+
Required when ``path`` is a CSV or when an NPZ does not include
|
|
56
|
+
``batch``. A single-column CSV with a sample-id index column.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
X : pd.DataFrame
|
|
61
|
+
batch : pd.Series
|
|
62
|
+
Indexed by ``X.index``.
|
|
63
|
+
extras : dict
|
|
64
|
+
Any additional arrays found in an NPZ (beyond X / index /
|
|
65
|
+
columns / batch). Empty for CSV inputs.
|
|
66
|
+
"""
|
|
67
|
+
if path.suffix.lower() == ".npz":
|
|
68
|
+
with np.load(path, allow_pickle=True) as npz:
|
|
69
|
+
if "X" not in npz.files:
|
|
70
|
+
raise typer.BadParameter(
|
|
71
|
+
f"{path} is missing the 'X' array. Expected "
|
|
72
|
+
f"np.savez(..., X=, [columns=, index=, batch=])."
|
|
73
|
+
)
|
|
74
|
+
X_arr = np.asarray(npz["X"])
|
|
75
|
+
columns = list(npz["columns"]) if "columns" in npz.files else None
|
|
76
|
+
index = list(npz["index"]) if "index" in npz.files else None
|
|
77
|
+
npz_batch = (
|
|
78
|
+
pd.Series(np.asarray(npz["batch"])) if "batch" in npz.files else None
|
|
79
|
+
)
|
|
80
|
+
extras = {
|
|
81
|
+
k: np.asarray(npz[k])
|
|
82
|
+
for k in npz.files
|
|
83
|
+
if k not in {"X", "columns", "index", "batch"}
|
|
84
|
+
}
|
|
85
|
+
X = pd.DataFrame(X_arr, index=index, columns=columns)
|
|
86
|
+
if npz_batch is not None:
|
|
87
|
+
if len(npz_batch) != len(X):
|
|
88
|
+
raise typer.BadParameter(
|
|
89
|
+
f"NPZ batch length {len(npz_batch)} does not match X rows {len(X)}."
|
|
90
|
+
)
|
|
91
|
+
npz_batch.index = X.index
|
|
92
|
+
batch = npz_batch
|
|
93
|
+
else:
|
|
94
|
+
if batch_csv is None:
|
|
95
|
+
raise typer.BadParameter(
|
|
96
|
+
f"No 'batch' array in {path}; pass --batch-csv."
|
|
97
|
+
)
|
|
98
|
+
batch = load_labels_csv(batch_csv, X.index)
|
|
99
|
+
return X, batch, extras
|
|
100
|
+
|
|
101
|
+
# CSV path
|
|
102
|
+
X = _read_csv_indexed(path)
|
|
103
|
+
if batch_csv is None:
|
|
104
|
+
raise typer.BadParameter(
|
|
105
|
+
"CSV input requires --batch-csv with a single-column file of "
|
|
106
|
+
"batch labels (first column is the sample index)."
|
|
107
|
+
)
|
|
108
|
+
batch = load_labels_csv(batch_csv, X.index)
|
|
109
|
+
return X, batch, {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_labels_csv(path: Path, index: pd.Index) -> pd.Series:
|
|
113
|
+
"""Load a single-column sidecar CSV as a Series aligned to ``index``.
|
|
114
|
+
|
|
115
|
+
The CSV's first column is interpreted as the sample index; the
|
|
116
|
+
second column is the label value. If the CSV has no explicit sample
|
|
117
|
+
index (i.e. a single data column read with the default
|
|
118
|
+
``RangeIndex``), the labels are assumed to already be in ``X`` order
|
|
119
|
+
and are aligned positionally.
|
|
120
|
+
"""
|
|
121
|
+
df = pd.read_csv(path, index_col=0)
|
|
122
|
+
if df.shape[1] == 0:
|
|
123
|
+
# Only an index column was present -> data is the index itself
|
|
124
|
+
raise typer.BadParameter(
|
|
125
|
+
f"{path} has no data columns. Expected <sample_id>,<label>."
|
|
126
|
+
)
|
|
127
|
+
if df.shape[1] > 1:
|
|
128
|
+
raise typer.BadParameter(
|
|
129
|
+
f"{path} has {df.shape[1]} data columns; expected exactly one. "
|
|
130
|
+
f"For multi-column covariates use a dedicated "
|
|
131
|
+
f"--*-covariates-csv / --design-csv flag."
|
|
132
|
+
)
|
|
133
|
+
series = df.iloc[:, 0]
|
|
134
|
+
return _align_series(series, index, source=path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_dataframe_csv(path: Path, index: pd.Index) -> pd.DataFrame:
|
|
138
|
+
"""Load a multi-column sidecar CSV and align it to ``index``."""
|
|
139
|
+
df = pd.read_csv(path, index_col=0)
|
|
140
|
+
if df.shape[1] == 0:
|
|
141
|
+
raise typer.BadParameter(f"{path} has no data columns.")
|
|
142
|
+
return _align_frame(df, index, source=path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_mz_csv(path: Path, n_features: int) -> np.ndarray:
|
|
146
|
+
"""Load an m/z axis CSV (single column, length = n_features)."""
|
|
147
|
+
series = pd.read_csv(path).iloc[:, -1]
|
|
148
|
+
values = series.to_numpy(dtype=float)
|
|
149
|
+
if values.shape[0] != n_features:
|
|
150
|
+
raise typer.BadParameter(
|
|
151
|
+
f"--mz-csv length {values.shape[0]} does not match n_features {n_features}."
|
|
152
|
+
)
|
|
153
|
+
return values
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def save_matrix(
|
|
157
|
+
X: pd.DataFrame,
|
|
158
|
+
path: Path,
|
|
159
|
+
*,
|
|
160
|
+
batch: pd.Series | None = None,
|
|
161
|
+
extras: dict[str, np.ndarray] | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Write ``X`` to CSV (index + features) or NPZ (bundled arrays)."""
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
if path.suffix.lower() == ".npz":
|
|
166
|
+
payload: dict[str, np.ndarray] = {
|
|
167
|
+
"X": X.to_numpy(),
|
|
168
|
+
"columns": np.asarray(X.columns),
|
|
169
|
+
"index": np.asarray(X.index),
|
|
170
|
+
}
|
|
171
|
+
if batch is not None:
|
|
172
|
+
payload["batch"] = np.asarray(batch.to_numpy())
|
|
173
|
+
if extras:
|
|
174
|
+
for k, v in extras.items():
|
|
175
|
+
if k in payload:
|
|
176
|
+
continue
|
|
177
|
+
payload[k] = np.asarray(v)
|
|
178
|
+
np.savez(path, **payload)
|
|
179
|
+
else:
|
|
180
|
+
X.to_csv(path)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _align_series(series: pd.Series, index: pd.Index, *, source: Path) -> pd.Series:
|
|
184
|
+
if series.index.equals(index):
|
|
185
|
+
return series
|
|
186
|
+
if set(index).issubset(set(series.index)):
|
|
187
|
+
return series.reindex(index)
|
|
188
|
+
if len(series) == len(index):
|
|
189
|
+
# Positional fallback: the CSV did not carry the X sample IDs.
|
|
190
|
+
aligned = series.copy()
|
|
191
|
+
aligned.index = index
|
|
192
|
+
return aligned
|
|
193
|
+
raise typer.BadParameter(
|
|
194
|
+
f"{source}: cannot align labels to X.index. "
|
|
195
|
+
f"Labels have {len(series)} rows, X has {len(index)} rows, "
|
|
196
|
+
f"and label indices do not cover X.index."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _align_frame(df: pd.DataFrame, index: pd.Index, *, source: Path) -> pd.DataFrame:
|
|
201
|
+
if df.index.equals(index):
|
|
202
|
+
return df
|
|
203
|
+
if set(index).issubset(set(df.index)):
|
|
204
|
+
return df.reindex(index)
|
|
205
|
+
if len(df) == len(index):
|
|
206
|
+
aligned = df.copy()
|
|
207
|
+
aligned.index = index
|
|
208
|
+
return aligned
|
|
209
|
+
raise typer.BadParameter(
|
|
210
|
+
f"{source}: cannot align to X.index. Got {len(df)} rows, X has {len(index)}."
|
|
211
|
+
)
|
maldibatchkit/_utils.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Internal helpers shared across MaldiBatchKit corrections."""
|
|
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
|
+
ArrayLike = pd.DataFrame | pd.Series | npt.NDArray[Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _as_dataframe(X: ArrayLike) -> tuple[pd.DataFrame, bool]:
|
|
15
|
+
"""Coerce ``X`` to a ``pd.DataFrame``.
|
|
16
|
+
|
|
17
|
+
Returns
|
|
18
|
+
-------
|
|
19
|
+
df : pd.DataFrame
|
|
20
|
+
The input as a DataFrame (copy if necessary so mutation is safe).
|
|
21
|
+
was_dataframe : bool
|
|
22
|
+
``True`` if the caller passed a DataFrame (so caller can choose to
|
|
23
|
+
return a DataFrame); ``False`` otherwise.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(X, pd.DataFrame):
|
|
26
|
+
return X, True
|
|
27
|
+
arr = np.asarray(X)
|
|
28
|
+
if arr.ndim == 1:
|
|
29
|
+
arr = arr.reshape(-1, 1)
|
|
30
|
+
return pd.DataFrame(arr), False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.Series | pd.DataFrame | None:
|
|
34
|
+
"""Align a batch/covariate array-like to ``idx``.
|
|
35
|
+
|
|
36
|
+
Mirrors ``combatlearn._utils._subset`` so all MaldiBatchKit correctors
|
|
37
|
+
behave the same way: the caller stores ``batch`` at ``__init__`` time
|
|
38
|
+
and we select the rows matching ``X.index`` at ``fit`` / ``transform``.
|
|
39
|
+
|
|
40
|
+
When ``obj`` is a pandas object it is sliced with ``.loc[idx]`` (which
|
|
41
|
+
naturally supports train/test splits). A numpy array or list is wrapped
|
|
42
|
+
in a Series/DataFrame using ``idx`` directly - the user is responsible
|
|
43
|
+
for making sure the ordering matches ``X``.
|
|
44
|
+
"""
|
|
45
|
+
if obj is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(obj, pd.Series | pd.DataFrame):
|
|
48
|
+
try:
|
|
49
|
+
return obj.loc[idx]
|
|
50
|
+
except KeyError as exc:
|
|
51
|
+
missing = [k for k in idx if k not in obj.index]
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"batch/covariate index does not cover all of X.index. "
|
|
54
|
+
f"{len(missing)} missing key(s) (first 3): {missing[:3]}. "
|
|
55
|
+
f"Ensure your batch labels are indexed by the same sample IDs "
|
|
56
|
+
f"used by X, or pass a bare numpy array when X has a default "
|
|
57
|
+
f"RangeIndex."
|
|
58
|
+
) from exc
|
|
59
|
+
arr = np.asarray(obj)
|
|
60
|
+
if arr.ndim == 1:
|
|
61
|
+
return pd.Series(arr, index=idx)
|
|
62
|
+
return pd.DataFrame(arr, index=idx)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ensure_index(X: ArrayLike) -> pd.Index:
|
|
66
|
+
"""Return ``X.index`` if ``X`` is a pandas object, else a default RangeIndex."""
|
|
67
|
+
if isinstance(X, pd.DataFrame | pd.Series):
|
|
68
|
+
return X.index
|
|
69
|
+
return pd.RangeIndex(len(X))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _restore_output(X_corrected: pd.DataFrame, was_dataframe: bool) -> Any:
|
|
73
|
+
"""Return a DataFrame if the input was a DataFrame, otherwise a numpy array."""
|
|
74
|
+
if was_dataframe:
|
|
75
|
+
return X_corrected
|
|
76
|
+
return X_corrected.to_numpy()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_mz_axis(
|
|
80
|
+
df: pd.DataFrame, mz_values: ArrayLike | None
|
|
81
|
+
) -> tuple[npt.NDArray[np.float64], bool]:
|
|
82
|
+
"""Resolve the m/z axis for an ``(n_samples, n_features)`` feature matrix.
|
|
83
|
+
|
|
84
|
+
Resolution order:
|
|
85
|
+
|
|
86
|
+
1. If the caller passed ``mz_values`` explicitly, use it verbatim
|
|
87
|
+
(length is validated against ``df.shape[1]``). Return
|
|
88
|
+
``(mz, True)``.
|
|
89
|
+
2. Else, try to coerce ``df.columns`` to ``float`` - this is the
|
|
90
|
+
case for matrices coming straight from
|
|
91
|
+
:class:`maldiamrkit.MaldiSet`, whose columns are the binned m/z
|
|
92
|
+
values (string-typed in the current MaldiAMRKit release, but
|
|
93
|
+
numerically convertible, e.g. ``'2000.0'`` ...). Return
|
|
94
|
+
``(mz, True)`` when it works.
|
|
95
|
+
3. Fall back to ``np.arange(df.shape[1])`` so ndarray inputs still
|
|
96
|
+
produce a plot - but signal to the caller that the axis is
|
|
97
|
+
bin indices rather than m/z, by returning ``(mz, False)``.
|
|
98
|
+
"""
|
|
99
|
+
n_features = df.shape[1]
|
|
100
|
+
if mz_values is not None:
|
|
101
|
+
mz = np.asarray(mz_values, dtype=float)
|
|
102
|
+
if mz.shape[0] != n_features:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"mz_values has length {mz.shape[0]} but X has {n_features} columns."
|
|
105
|
+
)
|
|
106
|
+
return mz, True
|
|
107
|
+
# pandas.DataFrame(ndarray) yields a default RangeIndex(0, n) -
|
|
108
|
+
# that is positional, not an m/z axis. Treat it as bin indices.
|
|
109
|
+
if isinstance(df.columns, pd.RangeIndex):
|
|
110
|
+
return np.arange(n_features, dtype=float), False
|
|
111
|
+
try:
|
|
112
|
+
mz = np.asarray(df.columns, dtype=float)
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
return np.arange(n_features, dtype=float), False
|
|
115
|
+
if np.any(np.isnan(mz)):
|
|
116
|
+
return np.arange(n_features, dtype=float), False
|
|
117
|
+
# If the coerced axis is exactly ``[0, 1, ..., n-1]`` it is an
|
|
118
|
+
# integer-labelled DataFrame that happens to parse as floats;
|
|
119
|
+
# the caller almost certainly did not mean those as m/z.
|
|
120
|
+
if np.array_equal(mz, np.arange(n_features, dtype=float)):
|
|
121
|
+
return mz, False
|
|
122
|
+
return mz, True
|