MaldiDeepKit 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.
- maldideepkit/__init__.py +54 -0
- maldideepkit/_bin_scaling.py +52 -0
- maldideepkit/_blocks.py +80 -0
- maldideepkit/attention/__init__.py +5 -0
- maldideepkit/attention/mlp.py +319 -0
- maldideepkit/augment/__init__.py +15 -0
- maldideepkit/augment/mixing.py +113 -0
- maldideepkit/augment/spectra.py +251 -0
- maldideepkit/base/__init__.py +15 -0
- maldideepkit/base/classifier.py +1079 -0
- maldideepkit/base/data.py +322 -0
- maldideepkit/blocks.py +39 -0
- maldideepkit/cnn/__init__.py +5 -0
- maldideepkit/cnn/cnn.py +316 -0
- maldideepkit/py.typed +0 -0
- maldideepkit/resnet/__init__.py +5 -0
- maldideepkit/resnet/resnet.py +380 -0
- maldideepkit/transformer/__init__.py +7 -0
- maldideepkit/transformer/transformer.py +492 -0
- maldideepkit/utils/__init__.py +22 -0
- maldideepkit/utils/calibration.py +134 -0
- maldideepkit/utils/ensemble.py +132 -0
- maldideepkit/utils/loss.py +138 -0
- maldideepkit/utils/lr_finder.py +173 -0
- maldideepkit/utils/reproducibility.py +70 -0
- maldideepkit/utils/sam.py +121 -0
- maldideepkit/utils/training.py +386 -0
- maldideepkit-0.1.0.dist-info/METADATA +301 -0
- maldideepkit-0.1.0.dist-info/RECORD +32 -0
- maldideepkit-0.1.0.dist-info/WHEEL +5 -0
- maldideepkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- maldideepkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Dataset and DataLoader helpers for MALDI-TOF spectra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import torch
|
|
10
|
+
from sklearn.model_selection import train_test_split
|
|
11
|
+
from torch.utils.data import DataLoader, Dataset
|
|
12
|
+
|
|
13
|
+
_STD_FLOOR = 1e-7
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_numpy(X: Any) -> np.ndarray:
|
|
17
|
+
"""Return ``X`` as a float32 2-D ndarray.
|
|
18
|
+
|
|
19
|
+
Accepts NumPy arrays, pandas DataFrames/Series, and any object
|
|
20
|
+
exposing a DataFrame-like ``.X`` attribute (e.g. :class:`maldiamrkit.MaldiSet`).
|
|
21
|
+
"""
|
|
22
|
+
if hasattr(X, "X") and not isinstance(X, np.ndarray):
|
|
23
|
+
X = X.X
|
|
24
|
+
if hasattr(X, "to_numpy"):
|
|
25
|
+
X = X.to_numpy()
|
|
26
|
+
X = np.asarray(X, dtype=np.float32)
|
|
27
|
+
if X.ndim == 1:
|
|
28
|
+
X = X.reshape(1, -1)
|
|
29
|
+
if X.ndim != 2:
|
|
30
|
+
raise ValueError(f"Expected 2-D feature matrix, got shape {X.shape} instead.")
|
|
31
|
+
return X
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SpectralDataset(Dataset):
|
|
35
|
+
"""PyTorch ``Dataset`` wrapping a binned MALDI-TOF feature matrix.
|
|
36
|
+
|
|
37
|
+
The dataset stores its spectra as a single float32 tensor in memory
|
|
38
|
+
and optionally standardizes each feature on the fly using
|
|
39
|
+
statistics computed once at construction time.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
X : array-like or MaldiSet
|
|
44
|
+
Feature matrix of shape ``(n_samples, n_bins)``. A NumPy array,
|
|
45
|
+
a pandas DataFrame, or any object with a DataFrame-like ``.X``
|
|
46
|
+
attribute is accepted.
|
|
47
|
+
y : array-like, optional
|
|
48
|
+
Integer class labels of shape ``(n_samples,)``. When ``None``
|
|
49
|
+
(inference usage) the dataset yields only features.
|
|
50
|
+
standardize : bool, default=False
|
|
51
|
+
If ``True``, subtract the per-column mean and divide by the
|
|
52
|
+
per-column standard deviation computed from ``X``. Columns with
|
|
53
|
+
zero variance are left untouched.
|
|
54
|
+
mean : array-like, optional
|
|
55
|
+
Pre-computed per-feature means. Used together with ``std`` to
|
|
56
|
+
apply an external standardization (e.g. one fitted on a training
|
|
57
|
+
fold). Ignored when ``standardize=False``.
|
|
58
|
+
std : array-like, optional
|
|
59
|
+
Pre-computed per-feature standard deviations. Ignored when
|
|
60
|
+
``standardize=False``.
|
|
61
|
+
|
|
62
|
+
Attributes
|
|
63
|
+
----------
|
|
64
|
+
X : torch.Tensor
|
|
65
|
+
Stored features as a float32 tensor.
|
|
66
|
+
y : torch.Tensor or None
|
|
67
|
+
Stored labels as a long tensor, or ``None`` for inference.
|
|
68
|
+
mean : torch.Tensor or None
|
|
69
|
+
Feature-wise mean used for standardization.
|
|
70
|
+
std : torch.Tensor or None
|
|
71
|
+
Feature-wise standard deviation used for standardization.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
X: Any,
|
|
77
|
+
y: Any | None = None,
|
|
78
|
+
*,
|
|
79
|
+
standardize: bool = False,
|
|
80
|
+
mean: np.ndarray | None = None,
|
|
81
|
+
std: np.ndarray | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
X_np = _to_numpy(X)
|
|
84
|
+
|
|
85
|
+
if standardize:
|
|
86
|
+
if mean is None or std is None:
|
|
87
|
+
mean = X_np.mean(axis=0)
|
|
88
|
+
std = X_np.std(axis=0)
|
|
89
|
+
std = np.asarray(std, dtype=np.float32)
|
|
90
|
+
mean = np.asarray(mean, dtype=np.float32)
|
|
91
|
+
safe_std = np.maximum(std, _STD_FLOOR).astype(np.float32)
|
|
92
|
+
X_np = (X_np - mean) / safe_std
|
|
93
|
+
self.mean: torch.Tensor | None = torch.from_numpy(
|
|
94
|
+
np.asarray(mean, dtype=np.float32)
|
|
95
|
+
)
|
|
96
|
+
self.std: torch.Tensor | None = torch.from_numpy(safe_std)
|
|
97
|
+
else:
|
|
98
|
+
self.mean = None
|
|
99
|
+
self.std = None
|
|
100
|
+
|
|
101
|
+
self.X = torch.from_numpy(X_np)
|
|
102
|
+
|
|
103
|
+
if y is not None:
|
|
104
|
+
if hasattr(y, "to_numpy"):
|
|
105
|
+
y = y.to_numpy()
|
|
106
|
+
y_np = np.asarray(y).ravel()
|
|
107
|
+
self.y: torch.Tensor | None = torch.from_numpy(y_np.astype(np.int64))
|
|
108
|
+
else:
|
|
109
|
+
self.y = None
|
|
110
|
+
|
|
111
|
+
def __len__(self) -> int:
|
|
112
|
+
return int(self.X.shape[0])
|
|
113
|
+
|
|
114
|
+
def __getitem__(self, idx: int) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
|
115
|
+
if self.y is None:
|
|
116
|
+
return self.X[idx]
|
|
117
|
+
return self.X[idx], self.y[idx]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
INPUT_TRANSFORMS = ("standardize", "log1p", "robust", "log1p+standardize", "none")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fit_input_transform(X_tr: np.ndarray, mode: str) -> dict[str, Any]:
|
|
124
|
+
"""Compute per-bin statistics for the requested input-transform mode.
|
|
125
|
+
|
|
126
|
+
Fitted on the training split only to keep the pipeline leak-safe.
|
|
127
|
+
Returns a state dict that :func:`apply_input_transform` consumes
|
|
128
|
+
at training and inference time.
|
|
129
|
+
|
|
130
|
+
Supported modes:
|
|
131
|
+
|
|
132
|
+
- ``"none"``: identity; empty state.
|
|
133
|
+
- ``"standardize"``: per-bin ``(X - mean) / std`` from the train split.
|
|
134
|
+
- ``"log1p"``: element-wise ``log1p(clip(X, 0, None))``; stateless.
|
|
135
|
+
- ``"robust"``: per-bin ``(X - median) / IQR`` from the train split.
|
|
136
|
+
A zero IQR bin is treated as unit scale.
|
|
137
|
+
- ``"log1p+standardize"``: ``log1p`` first, then standardize.
|
|
138
|
+
"""
|
|
139
|
+
if mode not in INPUT_TRANSFORMS:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Unknown input_transform={mode!r}; expected one of {INPUT_TRANSFORMS}."
|
|
142
|
+
)
|
|
143
|
+
state: dict[str, Any] = {"mode": mode}
|
|
144
|
+
if mode == "log1p+standardize":
|
|
145
|
+
X_fit = np.log1p(np.clip(X_tr, 0, None))
|
|
146
|
+
else:
|
|
147
|
+
X_fit = X_tr
|
|
148
|
+
|
|
149
|
+
if mode in {"standardize", "log1p+standardize"}:
|
|
150
|
+
state["mean"] = X_fit.mean(axis=0).astype(np.float32)
|
|
151
|
+
state["std"] = X_fit.std(axis=0).astype(np.float32)
|
|
152
|
+
elif mode == "robust":
|
|
153
|
+
state["median"] = np.median(X_fit, axis=0).astype(np.float32)
|
|
154
|
+
q75, q25 = np.percentile(X_fit, [75, 25], axis=0)
|
|
155
|
+
iqr = (q75 - q25).astype(np.float32)
|
|
156
|
+
state["iqr"] = np.maximum(iqr, _STD_FLOOR).astype(np.float32)
|
|
157
|
+
return state
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def apply_input_transform(X: np.ndarray, state: dict[str, Any]) -> np.ndarray:
|
|
161
|
+
"""Apply a fitted :func:`fit_input_transform` state to ``X``."""
|
|
162
|
+
mode = state.get("mode", "none")
|
|
163
|
+
if mode == "none":
|
|
164
|
+
return X.astype(np.float32, copy=False)
|
|
165
|
+
if mode == "log1p":
|
|
166
|
+
return np.log1p(np.clip(X, 0, None)).astype(np.float32, copy=False)
|
|
167
|
+
if mode == "standardize":
|
|
168
|
+
mean = np.asarray(state["mean"], dtype=np.float32)
|
|
169
|
+
std = np.asarray(state["std"], dtype=np.float32)
|
|
170
|
+
safe_std = np.maximum(std, _STD_FLOOR).astype(np.float32)
|
|
171
|
+
return ((X - mean) / safe_std).astype(np.float32, copy=False)
|
|
172
|
+
if mode == "log1p+standardize":
|
|
173
|
+
logged = np.log1p(np.clip(X, 0, None))
|
|
174
|
+
mean = np.asarray(state["mean"], dtype=np.float32)
|
|
175
|
+
std = np.asarray(state["std"], dtype=np.float32)
|
|
176
|
+
safe_std = np.maximum(std, _STD_FLOOR).astype(np.float32)
|
|
177
|
+
return ((logged - mean) / safe_std).astype(np.float32, copy=False)
|
|
178
|
+
if mode == "robust":
|
|
179
|
+
median = np.asarray(state["median"], dtype=np.float32)
|
|
180
|
+
iqr = np.asarray(state["iqr"], dtype=np.float32)
|
|
181
|
+
return ((X - median) / iqr).astype(np.float32, copy=False)
|
|
182
|
+
raise ValueError(f"Unknown input_transform state mode={mode!r}.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _warp_numpy(warper: Any, X_np: np.ndarray) -> np.ndarray:
|
|
186
|
+
"""Apply a fitted warper to a numpy matrix, returning a numpy matrix."""
|
|
187
|
+
df = pd.DataFrame(X_np)
|
|
188
|
+
out = warper.transform(df)
|
|
189
|
+
if hasattr(out, "to_numpy"):
|
|
190
|
+
out = out.to_numpy()
|
|
191
|
+
return np.asarray(out, dtype=np.float32)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def make_loaders(
|
|
195
|
+
X: Any,
|
|
196
|
+
y: Any,
|
|
197
|
+
*,
|
|
198
|
+
batch_size: int = 32,
|
|
199
|
+
val_size: float = 0.1,
|
|
200
|
+
random_state: int | None = 0,
|
|
201
|
+
standardize: bool = False,
|
|
202
|
+
input_transform: str | None = None,
|
|
203
|
+
stratify: bool = True,
|
|
204
|
+
num_workers: int = 0,
|
|
205
|
+
warper: Any | None = None,
|
|
206
|
+
) -> tuple[DataLoader, DataLoader, dict[str, Any]]:
|
|
207
|
+
"""Build stratified train / validation :class:`DataLoader` pairs.
|
|
208
|
+
|
|
209
|
+
Pipeline order, applied **after** the train/val split so nothing
|
|
210
|
+
from the validation split leaks into training statistics:
|
|
211
|
+
|
|
212
|
+
1. Spectral warping / alignment (if ``warper`` is given): fit on
|
|
213
|
+
the training split, then transform both splits.
|
|
214
|
+
2. Per-feature standardization (if ``standardize=True``): fit
|
|
215
|
+
mean/std on the (warped) training split, then apply to both
|
|
216
|
+
splits.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
X : array-like or MaldiSet
|
|
221
|
+
Feature matrix of shape ``(n_samples, n_bins)``.
|
|
222
|
+
y : array-like
|
|
223
|
+
Integer class labels of shape ``(n_samples,)``.
|
|
224
|
+
batch_size : int, default=32
|
|
225
|
+
Mini-batch size for the training loader.
|
|
226
|
+
val_size : float, default=0.1
|
|
227
|
+
Fraction of the input held out for validation.
|
|
228
|
+
random_state : int or None, default=0
|
|
229
|
+
Seed for the split.
|
|
230
|
+
standardize : bool, default=False
|
|
231
|
+
Shorthand for ``input_transform="standardize"`` (when True) or
|
|
232
|
+
``input_transform="none"`` (when False). Kept for backwards
|
|
233
|
+
compatibility; the modern interface is ``input_transform``.
|
|
234
|
+
Ignored whenever ``input_transform`` is given explicitly.
|
|
235
|
+
input_transform : str, optional
|
|
236
|
+
One of ``{"none", "standardize", "log1p", "robust",
|
|
237
|
+
"log1p+standardize"}``. Fitted on the (warped) training split
|
|
238
|
+
only and applied to both splits. Overrides ``standardize``
|
|
239
|
+
when both are given.
|
|
240
|
+
stratify : bool, default=True
|
|
241
|
+
If ``True`` and all classes have at least two samples, stratify
|
|
242
|
+
the split on ``y``. Falls back to random split otherwise.
|
|
243
|
+
num_workers : int, default=0
|
|
244
|
+
``DataLoader`` worker count.
|
|
245
|
+
warper : sklearn-style transformer, optional
|
|
246
|
+
Unfitted spectral-alignment transformer with ``fit(X) ->
|
|
247
|
+
self`` + ``transform(X) -> X``. Fitted on the training split
|
|
248
|
+
only and used to transform both splits. The fitted object is
|
|
249
|
+
returned in ``stats["warper"]``.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
train_loader : DataLoader
|
|
254
|
+
Shuffling training loader. Drops the last batch when it would
|
|
255
|
+
contain a single sample (avoids ``BatchNorm`` issues).
|
|
256
|
+
val_loader : DataLoader
|
|
257
|
+
Non-shuffling validation loader.
|
|
258
|
+
stats : dict
|
|
259
|
+
``{"mean": array or None, "std": array or None, "warper":
|
|
260
|
+
fitted warper or None, "input_transform_state": dict}``.
|
|
261
|
+
"""
|
|
262
|
+
X_np = _to_numpy(X)
|
|
263
|
+
if hasattr(y, "to_numpy"):
|
|
264
|
+
y = y.to_numpy()
|
|
265
|
+
y_np = np.asarray(y).ravel()
|
|
266
|
+
|
|
267
|
+
_, counts = np.unique(y_np, return_counts=True)
|
|
268
|
+
can_stratify = stratify and counts.min() >= 2
|
|
269
|
+
|
|
270
|
+
X_tr, X_val, y_tr, y_val = train_test_split(
|
|
271
|
+
X_np,
|
|
272
|
+
y_np,
|
|
273
|
+
test_size=val_size,
|
|
274
|
+
random_state=random_state,
|
|
275
|
+
stratify=y_np if can_stratify else None,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
fitted_warper = None
|
|
279
|
+
if warper is not None:
|
|
280
|
+
fitted_warper = warper.fit(pd.DataFrame(X_tr))
|
|
281
|
+
X_tr = _warp_numpy(fitted_warper, X_tr)
|
|
282
|
+
X_val = _warp_numpy(fitted_warper, X_val)
|
|
283
|
+
|
|
284
|
+
if input_transform is None:
|
|
285
|
+
transform_mode = "standardize" if standardize else "none"
|
|
286
|
+
else:
|
|
287
|
+
transform_mode = input_transform
|
|
288
|
+
transform_state = fit_input_transform(X_tr, transform_mode)
|
|
289
|
+
X_tr = apply_input_transform(X_tr, transform_state)
|
|
290
|
+
X_val = apply_input_transform(X_val, transform_state)
|
|
291
|
+
|
|
292
|
+
mean = transform_state.get("mean")
|
|
293
|
+
std = transform_state.get("std")
|
|
294
|
+
|
|
295
|
+
train_ds = SpectralDataset(X_tr, y_tr, standardize=False)
|
|
296
|
+
val_ds = SpectralDataset(X_val, y_val, standardize=False)
|
|
297
|
+
|
|
298
|
+
drop_last = batch_size > 1 and (len(train_ds) % batch_size) == 1
|
|
299
|
+
train_loader = DataLoader(
|
|
300
|
+
train_ds,
|
|
301
|
+
batch_size=batch_size,
|
|
302
|
+
shuffle=True,
|
|
303
|
+
drop_last=drop_last,
|
|
304
|
+
num_workers=num_workers,
|
|
305
|
+
)
|
|
306
|
+
val_loader = DataLoader(
|
|
307
|
+
val_ds,
|
|
308
|
+
batch_size=batch_size,
|
|
309
|
+
shuffle=False,
|
|
310
|
+
drop_last=False,
|
|
311
|
+
num_workers=num_workers,
|
|
312
|
+
)
|
|
313
|
+
return (
|
|
314
|
+
train_loader,
|
|
315
|
+
val_loader,
|
|
316
|
+
{
|
|
317
|
+
"mean": mean,
|
|
318
|
+
"std": std,
|
|
319
|
+
"warper": fitted_warper,
|
|
320
|
+
"input_transform_state": transform_state,
|
|
321
|
+
},
|
|
322
|
+
)
|
maldideepkit/blocks.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""One-stop import path for every MaldiDeepKit ``nn.Module`` primitive.
|
|
2
|
+
|
|
3
|
+
Re-exports the full backbones and composable primitives under a single
|
|
4
|
+
namespace so users embedding components into their own networks don't
|
|
5
|
+
have to know the per-family layout.
|
|
6
|
+
|
|
7
|
+
Examples
|
|
8
|
+
--------
|
|
9
|
+
>>> import torch
|
|
10
|
+
>>> from maldideepkit.blocks import SpectralTransformer1D, TransformerBlock
|
|
11
|
+
>>> backbone = SpectralTransformer1D(input_dim=6000, depth=6)
|
|
12
|
+
>>> block = TransformerBlock(dim=128, num_heads=4)
|
|
13
|
+
>>> tokens = torch.randn(2, 1500, 128)
|
|
14
|
+
>>> out = block(tokens) # (2, 1500, 128)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._blocks import DropPath, PatchEmbed1D
|
|
20
|
+
from .attention.mlp import SpectralAttentionMLP
|
|
21
|
+
from .cnn.cnn import SpectralCNN1D
|
|
22
|
+
from .resnet.resnet import BasicBlock1D, SpectralResNet1D
|
|
23
|
+
from .transformer.transformer import (
|
|
24
|
+
MultiHeadSelfAttention,
|
|
25
|
+
SpectralTransformer1D,
|
|
26
|
+
TransformerBlock,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"SpectralAttentionMLP",
|
|
31
|
+
"SpectralCNN1D",
|
|
32
|
+
"SpectralResNet1D",
|
|
33
|
+
"BasicBlock1D",
|
|
34
|
+
"SpectralTransformer1D",
|
|
35
|
+
"TransformerBlock",
|
|
36
|
+
"MultiHeadSelfAttention",
|
|
37
|
+
"PatchEmbed1D",
|
|
38
|
+
"DropPath",
|
|
39
|
+
]
|
maldideepkit/cnn/cnn.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""1-D convolutional classifier for binned MALDI-TOF spectra.
|
|
2
|
+
|
|
3
|
+
A stack of ``Conv1d -> BatchNorm -> ReLU -> MaxPool`` blocks followed
|
|
4
|
+
by a flatten + dense classification head.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
from torch import nn
|
|
15
|
+
|
|
16
|
+
from .._bin_scaling import scale_odd_kernel
|
|
17
|
+
from ..base.classifier import BaseSpectralClassifier
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _broadcast(value: int | Sequence[int], n: int, name: str) -> tuple[int, ...]:
|
|
21
|
+
"""Return a length-``n`` tuple: scalars are broadcast, sequences validated."""
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
if value <= 0:
|
|
24
|
+
raise ValueError(f"{name} must be a positive integer; got {value}.")
|
|
25
|
+
return (value,) * n
|
|
26
|
+
out = tuple(int(v) for v in value)
|
|
27
|
+
if len(out) != n:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"{name} has length {len(out)} but must have length {n} to match channels."
|
|
30
|
+
)
|
|
31
|
+
if any(v <= 0 for v in out):
|
|
32
|
+
raise ValueError(f"{name} must contain only positive integers; got {out}.")
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ConvBlock(nn.Module):
|
|
37
|
+
"""One Conv1D + BN + ReLU + MaxPool + Dropout block."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
in_channels: int,
|
|
42
|
+
out_channels: int,
|
|
43
|
+
kernel_size: int,
|
|
44
|
+
pool_size: int,
|
|
45
|
+
dropout: float,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
padding = kernel_size // 2
|
|
49
|
+
self.block = nn.Sequential(
|
|
50
|
+
nn.Conv1d(in_channels, out_channels, kernel_size, padding=padding),
|
|
51
|
+
nn.BatchNorm1d(out_channels),
|
|
52
|
+
nn.ReLU(inplace=True),
|
|
53
|
+
nn.MaxPool1d(pool_size),
|
|
54
|
+
nn.Dropout(dropout),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
58
|
+
return self.block(x)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SpectralCNN1D(nn.Module):
|
|
62
|
+
"""Stack of Conv1D blocks with a dense classification head.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
input_dim : int
|
|
67
|
+
Number of input bins.
|
|
68
|
+
n_classes : int, default=2
|
|
69
|
+
Number of output logits.
|
|
70
|
+
channels : sequence of int, default=(32, 64, 128, 128)
|
|
71
|
+
Output channels of each convolutional block.
|
|
72
|
+
kernel_size : int or sequence of int, default=7
|
|
73
|
+
Kernel size per block. A scalar is broadcast to every block;
|
|
74
|
+
a sequence must have the same length as ``channels``.
|
|
75
|
+
pool_size : int or sequence of int, default=2
|
|
76
|
+
Pool factor per block. A scalar is broadcast to every block;
|
|
77
|
+
a sequence must have the same length as ``channels``.
|
|
78
|
+
head_dim : int, default=128
|
|
79
|
+
Width of the single hidden dense layer.
|
|
80
|
+
dropout : float, default=0.3
|
|
81
|
+
Dropout applied inside every block and before the output layer.
|
|
82
|
+
|
|
83
|
+
Notes
|
|
84
|
+
-----
|
|
85
|
+
Input tensors have shape ``(batch, input_dim)`` and are unsqueezed to
|
|
86
|
+
``(batch, 1, input_dim)`` internally.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
input_dim: int,
|
|
92
|
+
n_classes: int = 2,
|
|
93
|
+
channels: tuple[int, ...] = (32, 64, 128, 128),
|
|
94
|
+
kernel_size: int | Sequence[int] = 7,
|
|
95
|
+
pool_size: int | Sequence[int] = 2,
|
|
96
|
+
head_dim: int = 128,
|
|
97
|
+
dropout: float = 0.3,
|
|
98
|
+
) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
n_blocks = len(channels)
|
|
101
|
+
kernels = _broadcast(kernel_size, n_blocks, "kernel_size")
|
|
102
|
+
pools = _broadcast(pool_size, n_blocks, "pool_size")
|
|
103
|
+
|
|
104
|
+
blocks: list[nn.Module] = []
|
|
105
|
+
prev = 1
|
|
106
|
+
length = input_dim
|
|
107
|
+
for out_ch, k, p in zip(channels, kernels, pools, strict=True):
|
|
108
|
+
blocks.append(_ConvBlock(prev, out_ch, k, p, dropout))
|
|
109
|
+
prev = out_ch
|
|
110
|
+
length //= p
|
|
111
|
+
if length <= 0:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"input_dim={input_dim} is too small for the given pool "
|
|
114
|
+
f"schedule {pools} (block {len(blocks)} would have 0 length)."
|
|
115
|
+
)
|
|
116
|
+
self.backbone = nn.Sequential(*blocks)
|
|
117
|
+
self.flat_dim = prev * length
|
|
118
|
+
self.head = nn.Sequential(
|
|
119
|
+
nn.Linear(self.flat_dim, head_dim),
|
|
120
|
+
nn.LayerNorm(head_dim),
|
|
121
|
+
nn.ReLU(inplace=True),
|
|
122
|
+
nn.Dropout(dropout),
|
|
123
|
+
nn.Linear(head_dim, n_classes),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
127
|
+
"""Map ``(batch, input_dim)`` to ``(batch, n_classes)`` logits."""
|
|
128
|
+
x = x.unsqueeze(1)
|
|
129
|
+
feat = self.backbone(x)
|
|
130
|
+
return self.head(feat.flatten(1))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class MaldiCNNClassifier(BaseSpectralClassifier):
|
|
134
|
+
"""sklearn-compatible 1-D CNN classifier for MALDI-TOF spectra.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
channels : sequence of int, default=(32, 64, 128, 128)
|
|
139
|
+
Output channels of each convolutional block. The effective spatial
|
|
140
|
+
resolution is divided by the corresponding ``pool_size`` after
|
|
141
|
+
every block.
|
|
142
|
+
kernel_size : int or sequence of int, default=7
|
|
143
|
+
Kernel size per block. A scalar is broadcast; a sequence must
|
|
144
|
+
match the length of ``channels``. The default is calibrated for
|
|
145
|
+
``bin_width=3``; :meth:`from_spectrum` scales it for other bin
|
|
146
|
+
widths.
|
|
147
|
+
pool_size : int or sequence of int, default=2
|
|
148
|
+
Pool factor per block. Accepts scalar or per-block sequence.
|
|
149
|
+
head_dim : int, default=128
|
|
150
|
+
Width of the hidden dense layer.
|
|
151
|
+
dropout : float, default=0.3
|
|
152
|
+
Dropout applied inside every block and before the output layer.
|
|
153
|
+
**kwargs
|
|
154
|
+
Forwarded to :class:`~maldideepkit.base.classifier.BaseSpectralClassifier`.
|
|
155
|
+
|
|
156
|
+
Notes
|
|
157
|
+
-----
|
|
158
|
+
The flat dense head scales linearly with ``input_dim``; prefer
|
|
159
|
+
:class:`~maldideepkit.MaldiResNetClassifier` or
|
|
160
|
+
:class:`~maldideepkit.MaldiTransformerClassifier` if you want
|
|
161
|
+
a head width that's independent of the input resolution.
|
|
162
|
+
|
|
163
|
+
See :func:`from_spectrum` for a factory that auto-scales
|
|
164
|
+
``kernel_size`` for a given ``(bin_width, input_dim)`` layout.
|
|
165
|
+
|
|
166
|
+
Examples
|
|
167
|
+
--------
|
|
168
|
+
>>> import numpy as np
|
|
169
|
+
>>> from maldideepkit import MaldiCNNClassifier
|
|
170
|
+
>>> rng = np.random.default_rng(0)
|
|
171
|
+
>>> X = rng.standard_normal((32, 256)).astype("float32")
|
|
172
|
+
>>> y = rng.integers(0, 2, size=32)
|
|
173
|
+
>>> clf = MaldiCNNClassifier(epochs=2, batch_size=8, random_state=0).fit(X, y)
|
|
174
|
+
>>> clf.predict(X).shape
|
|
175
|
+
(32,)
|
|
176
|
+
|
|
177
|
+
Per-block kernel progression:
|
|
178
|
+
|
|
179
|
+
>>> clf = MaldiCNNClassifier(
|
|
180
|
+
... channels=(32, 64, 128, 128),
|
|
181
|
+
... kernel_size=(11, 7, 5, 3),
|
|
182
|
+
... )
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
input_dim: int | None = None,
|
|
188
|
+
n_classes: int = 2,
|
|
189
|
+
channels: tuple[int, ...] = (32, 64, 128, 128),
|
|
190
|
+
kernel_size: int | Sequence[int] = 7,
|
|
191
|
+
pool_size: int | Sequence[int] = 2,
|
|
192
|
+
head_dim: int = 128,
|
|
193
|
+
dropout: float = 0.3,
|
|
194
|
+
learning_rate: float = 1e-3,
|
|
195
|
+
weight_decay: float = 0.0,
|
|
196
|
+
grad_clip_norm: float | None = None,
|
|
197
|
+
label_smoothing: float = 0.0,
|
|
198
|
+
loss: str = "cross_entropy",
|
|
199
|
+
focal_gamma: float = 2.0,
|
|
200
|
+
use_amp: bool = False,
|
|
201
|
+
swa_start_epoch: int | None = None,
|
|
202
|
+
tune_threshold: bool = False,
|
|
203
|
+
threshold_metric: str = "balanced_accuracy",
|
|
204
|
+
calibrate_temperature: bool = False,
|
|
205
|
+
min_val_auroc_for_threshold_tune: float = 0.6,
|
|
206
|
+
use_sam: bool = False,
|
|
207
|
+
sam_rho: float = 0.05,
|
|
208
|
+
batch_size: int = 32,
|
|
209
|
+
epochs: int = 100,
|
|
210
|
+
early_stopping_patience: int = 10,
|
|
211
|
+
val_fraction: float = 0.1,
|
|
212
|
+
warmup_epochs: int = 0,
|
|
213
|
+
standardize: bool = False,
|
|
214
|
+
input_transform: str | None = None,
|
|
215
|
+
warping: Any | None = None,
|
|
216
|
+
metrics_log_path: str | Path | None = None,
|
|
217
|
+
track_train_metrics: bool = False,
|
|
218
|
+
augment: Any | None = None,
|
|
219
|
+
mixup_alpha: float = 0.0,
|
|
220
|
+
cutmix_alpha: float = 0.0,
|
|
221
|
+
ema_decay: float | None = None,
|
|
222
|
+
retry_on_val_auroc_below: float | None = None,
|
|
223
|
+
max_retries: int = 2,
|
|
224
|
+
class_weight: str | np.ndarray | list | None = None,
|
|
225
|
+
device: str | torch.device = "auto",
|
|
226
|
+
random_state: int = 0,
|
|
227
|
+
verbose: bool = False,
|
|
228
|
+
) -> None:
|
|
229
|
+
super().__init__(
|
|
230
|
+
input_dim=input_dim,
|
|
231
|
+
n_classes=n_classes,
|
|
232
|
+
learning_rate=learning_rate,
|
|
233
|
+
weight_decay=weight_decay,
|
|
234
|
+
grad_clip_norm=grad_clip_norm,
|
|
235
|
+
label_smoothing=label_smoothing,
|
|
236
|
+
loss=loss,
|
|
237
|
+
focal_gamma=focal_gamma,
|
|
238
|
+
use_amp=use_amp,
|
|
239
|
+
swa_start_epoch=swa_start_epoch,
|
|
240
|
+
tune_threshold=tune_threshold,
|
|
241
|
+
threshold_metric=threshold_metric,
|
|
242
|
+
calibrate_temperature=calibrate_temperature,
|
|
243
|
+
min_val_auroc_for_threshold_tune=min_val_auroc_for_threshold_tune,
|
|
244
|
+
use_sam=use_sam,
|
|
245
|
+
sam_rho=sam_rho,
|
|
246
|
+
batch_size=batch_size,
|
|
247
|
+
epochs=epochs,
|
|
248
|
+
early_stopping_patience=early_stopping_patience,
|
|
249
|
+
val_fraction=val_fraction,
|
|
250
|
+
warmup_epochs=warmup_epochs,
|
|
251
|
+
standardize=standardize,
|
|
252
|
+
input_transform=input_transform,
|
|
253
|
+
warping=warping,
|
|
254
|
+
metrics_log_path=metrics_log_path,
|
|
255
|
+
track_train_metrics=track_train_metrics,
|
|
256
|
+
augment=augment,
|
|
257
|
+
mixup_alpha=mixup_alpha,
|
|
258
|
+
cutmix_alpha=cutmix_alpha,
|
|
259
|
+
ema_decay=ema_decay,
|
|
260
|
+
retry_on_val_auroc_below=retry_on_val_auroc_below,
|
|
261
|
+
max_retries=max_retries,
|
|
262
|
+
class_weight=class_weight,
|
|
263
|
+
device=device,
|
|
264
|
+
random_state=random_state,
|
|
265
|
+
verbose=verbose,
|
|
266
|
+
)
|
|
267
|
+
self.channels = channels
|
|
268
|
+
self.kernel_size = kernel_size
|
|
269
|
+
self.pool_size = pool_size
|
|
270
|
+
self.head_dim = head_dim
|
|
271
|
+
self.dropout = dropout
|
|
272
|
+
|
|
273
|
+
def _build_model(self) -> nn.Module:
|
|
274
|
+
return SpectralCNN1D(
|
|
275
|
+
input_dim=self.input_dim_,
|
|
276
|
+
n_classes=self.n_classes_,
|
|
277
|
+
channels=tuple(self.channels),
|
|
278
|
+
kernel_size=self.kernel_size,
|
|
279
|
+
pool_size=self.pool_size,
|
|
280
|
+
head_dim=int(self.head_dim),
|
|
281
|
+
dropout=float(self.dropout),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_spectrum(
|
|
286
|
+
cls, bin_width: int, input_dim: int, **overrides
|
|
287
|
+
) -> "MaldiCNNClassifier":
|
|
288
|
+
"""Construct a classifier with ``kernel_size`` scaled for ``bin_width``.
|
|
289
|
+
|
|
290
|
+
Scales ``kernel_size`` inversely with ``bin_width`` relative to
|
|
291
|
+
the package reference (``bin_width=3``, ``kernel_size=7``).
|
|
292
|
+
Any keyword in ``**overrides`` wins over the auto-scaled value.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
bin_width : int
|
|
297
|
+
Bin width in Daltons (e.g. 3 for the MaldiAMRKit default,
|
|
298
|
+
6 for coarser binning).
|
|
299
|
+
input_dim : int
|
|
300
|
+
Number of bins in the input. Stored on the classifier for
|
|
301
|
+
shape validation.
|
|
302
|
+
**overrides
|
|
303
|
+
Any additional keyword arguments override the scaled defaults.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
MaldiCNNClassifier
|
|
308
|
+
An unfitted estimator with ``kernel_size`` scaled for the
|
|
309
|
+
given ``bin_width``.
|
|
310
|
+
"""
|
|
311
|
+
kwargs: dict[str, Any] = {
|
|
312
|
+
"input_dim": input_dim,
|
|
313
|
+
"kernel_size": scale_odd_kernel(bin_width),
|
|
314
|
+
}
|
|
315
|
+
kwargs.update(overrides)
|
|
316
|
+
return cls(**kwargs)
|
maldideepkit/py.typed
ADDED
|
File without changes
|