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,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_
@@ -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
+ )
@@ -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