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
maldideepkit/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""MaldiDeepKit -- deep learning classifiers for MALDI-TOF binned spectra.
|
|
2
|
+
|
|
3
|
+
Provides a catalog of PyTorch architectures (MLP, CNN, ResNet,
|
|
4
|
+
Transformer) adapted to 1-D binned MALDI-TOF spectra, each wrapped in
|
|
5
|
+
a scikit-learn compatible estimator with sensible defaults.
|
|
6
|
+
|
|
7
|
+
Subpackages
|
|
8
|
+
-----------
|
|
9
|
+
- ``maldideepkit.base`` -- ``BaseSpectralClassifier``, ``SpectralDataset``,
|
|
10
|
+
``make_loaders``.
|
|
11
|
+
- ``maldideepkit.attention`` -- ``MaldiMLPClassifier`` (MLP with optional
|
|
12
|
+
sigmoid-gated attention).
|
|
13
|
+
- ``maldideepkit.cnn`` -- ``MaldiCNNClassifier`` (Conv1D blocks).
|
|
14
|
+
- ``maldideepkit.resnet`` -- ``MaldiResNetClassifier`` (1-D ResNet-18).
|
|
15
|
+
- ``maldideepkit.transformer`` -- ``MaldiTransformerClassifier`` (1-D ViT).
|
|
16
|
+
- ``maldideepkit.blocks`` -- re-exports of every backbone and
|
|
17
|
+
composable primitive for users embedding components into their own
|
|
18
|
+
networks.
|
|
19
|
+
- ``maldideepkit.utils`` -- reproducibility helpers and shared
|
|
20
|
+
training primitives.
|
|
21
|
+
|
|
22
|
+
Examples
|
|
23
|
+
--------
|
|
24
|
+
>>> import numpy as np
|
|
25
|
+
>>> from maldideepkit import MaldiMLPClassifier
|
|
26
|
+
>>> rng = np.random.default_rng(0)
|
|
27
|
+
>>> X = rng.standard_normal((64, 256)).astype("float32")
|
|
28
|
+
>>> y = rng.integers(0, 2, size=64)
|
|
29
|
+
>>> clf = MaldiMLPClassifier(epochs=2, batch_size=16, random_state=0)
|
|
30
|
+
>>> _ = clf.fit(X, y)
|
|
31
|
+
>>> proba = clf.predict_proba(X)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from .attention.mlp import MaldiMLPClassifier
|
|
35
|
+
from .base.classifier import BaseSpectralClassifier
|
|
36
|
+
from .base.data import SpectralDataset, make_loaders
|
|
37
|
+
from .cnn.cnn import MaldiCNNClassifier
|
|
38
|
+
from .resnet.resnet import MaldiResNetClassifier
|
|
39
|
+
from .transformer.transformer import MaldiTransformerClassifier
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
__author__ = "Ettore Rocchi"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"BaseSpectralClassifier",
|
|
46
|
+
"MaldiCNNClassifier",
|
|
47
|
+
"MaldiMLPClassifier",
|
|
48
|
+
"MaldiResNetClassifier",
|
|
49
|
+
"MaldiTransformerClassifier",
|
|
50
|
+
"SpectralDataset",
|
|
51
|
+
"__author__",
|
|
52
|
+
"__version__",
|
|
53
|
+
"make_loaders",
|
|
54
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Helpers to scale architectural hyperparameters with spectrum layout.
|
|
2
|
+
|
|
3
|
+
The package defaults are calibrated for ``bin_width=3`` over the
|
|
4
|
+
2000-20000 Da range (``input_dim=6000``). For other bin widths,
|
|
5
|
+
:func:`scale_odd_kernel` adjusts the conv kernel size inversely so
|
|
6
|
+
the receptive field in Daltons stays comparable.
|
|
7
|
+
|
|
8
|
+
See ``docs/source/spectrum_scaling.rst`` for the per-species
|
|
9
|
+
peak-width statistics underpinning the defaults.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
REFERENCE_BIN_WIDTH = 3
|
|
15
|
+
REFERENCE_CONV_KERNEL = 7
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def scale_odd_kernel(
|
|
19
|
+
bin_width: int,
|
|
20
|
+
*,
|
|
21
|
+
reference_kernel: int = REFERENCE_CONV_KERNEL,
|
|
22
|
+
reference_bin_width: int = REFERENCE_BIN_WIDTH,
|
|
23
|
+
min_kernel: int = 3,
|
|
24
|
+
) -> int:
|
|
25
|
+
"""Return an odd conv kernel scaled inversely with ``bin_width``.
|
|
26
|
+
|
|
27
|
+
Used by :class:`~maldideepkit.MaldiCNNClassifier` and
|
|
28
|
+
:class:`~maldideepkit.MaldiResNetClassifier` to adjust the first
|
|
29
|
+
convolutional layer's receptive field for non-default bin widths.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
bin_width : int
|
|
34
|
+
The target bin width in Daltons.
|
|
35
|
+
reference_kernel : int, default=7
|
|
36
|
+
Kernel size used at ``reference_bin_width`` (the package default).
|
|
37
|
+
reference_bin_width : int, default=3
|
|
38
|
+
Reference bin width at which ``reference_kernel`` was chosen.
|
|
39
|
+
min_kernel : int, default=3
|
|
40
|
+
Lower bound on the returned kernel size.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
int
|
|
45
|
+
Odd kernel size ``>= min_kernel`` closest to
|
|
46
|
+
``reference_kernel * reference_bin_width / bin_width``.
|
|
47
|
+
"""
|
|
48
|
+
target = reference_kernel * reference_bin_width / bin_width
|
|
49
|
+
k = max(min_kernel, round(target))
|
|
50
|
+
if k % 2 == 0:
|
|
51
|
+
k += 1
|
|
52
|
+
return k
|
maldideepkit/_blocks.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared ``nn.Module`` primitives used by more than one architecture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from einops import rearrange
|
|
7
|
+
from torch import nn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PatchEmbed1D(nn.Module):
|
|
11
|
+
"""1-D patch embedding via a strided Conv1D + LayerNorm.
|
|
12
|
+
|
|
13
|
+
Maps ``(B, C_in, L)`` to ``(B, L // patch_size, embed_dim)`` by
|
|
14
|
+
convolving with a non-overlapping kernel of width ``patch_size``
|
|
15
|
+
and stride ``patch_size``.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
patch_size : int
|
|
20
|
+
Non-overlapping patch width (and stride).
|
|
21
|
+
in_channels : int
|
|
22
|
+
Input channels. ``1`` for raw binned spectra after an
|
|
23
|
+
``unsqueeze(1)``.
|
|
24
|
+
embed_dim : int
|
|
25
|
+
Output embedding dimension.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, patch_size: int, in_channels: int, embed_dim: int) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.proj = nn.Conv1d(
|
|
31
|
+
in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
|
|
32
|
+
)
|
|
33
|
+
self.norm = nn.LayerNorm(embed_dim)
|
|
34
|
+
|
|
35
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
36
|
+
"""Map ``(B, C, L)`` to ``(B, L // patch_size, embed_dim)``."""
|
|
37
|
+
x = self.proj(x)
|
|
38
|
+
x = rearrange(x, "b c l -> b l c")
|
|
39
|
+
return self.norm(x)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DropPath(nn.Module):
|
|
43
|
+
"""Stochastic depth (per-sample residual drop).
|
|
44
|
+
|
|
45
|
+
During training, zero out a fraction ``drop_prob`` of samples
|
|
46
|
+
uniformly at random and rescale the survivors by
|
|
47
|
+
``1 / (1 - drop_prob)`` so the expectation is unchanged. At
|
|
48
|
+
inference time or when ``drop_prob == 0`` this is a no-op.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
drop_prob : float, default=0.0
|
|
53
|
+
Probability of dropping a sample's residual. Must be in
|
|
54
|
+
``[0, 1)``.
|
|
55
|
+
generator : torch.Generator or None, default=None
|
|
56
|
+
Optional explicit RNG for the Bernoulli mask. When ``None``
|
|
57
|
+
PyTorch's global generator is used.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
drop_prob: float = 0.0,
|
|
63
|
+
generator: torch.Generator | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
if not 0.0 <= drop_prob < 1.0:
|
|
67
|
+
raise ValueError(f"drop_prob must be in [0, 1); got {drop_prob!r}.")
|
|
68
|
+
self.drop_prob = float(drop_prob)
|
|
69
|
+
self.generator = generator
|
|
70
|
+
|
|
71
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
72
|
+
if self.drop_prob == 0.0 or not self.training:
|
|
73
|
+
return x
|
|
74
|
+
keep_prob = 1.0 - self.drop_prob
|
|
75
|
+
shape = (x.shape[0],) + (1,) * (x.ndim - 1)
|
|
76
|
+
mask = x.new_empty(shape).bernoulli_(keep_prob, generator=self.generator)
|
|
77
|
+
return x * mask / keep_prob
|
|
78
|
+
|
|
79
|
+
def extra_repr(self) -> str:
|
|
80
|
+
return f"drop_prob={self.drop_prob}"
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""MLP classifier with optional sigmoid-gated attention.
|
|
2
|
+
|
|
3
|
+
The architecture is a dense network with a learned per-feature gate on
|
|
4
|
+
the first hidden layer that doubles as an interpretable attention map
|
|
5
|
+
over the projected bin representation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import torch
|
|
15
|
+
from sklearn.utils.validation import check_is_fitted
|
|
16
|
+
from torch import nn
|
|
17
|
+
|
|
18
|
+
from ..base.classifier import BaseSpectralClassifier
|
|
19
|
+
from ..base.data import _to_numpy
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpectralAttentionMLP(nn.Module):
|
|
23
|
+
"""Projection + optional sigmoid-gated attention + deep MLP head.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
input_dim : int
|
|
28
|
+
Number of input bins. The first linear layer projects this down
|
|
29
|
+
to :attr:`hidden_dim`.
|
|
30
|
+
n_classes : int, default=2
|
|
31
|
+
Number of output logits.
|
|
32
|
+
hidden_dim : int, default=512
|
|
33
|
+
Width of the projection layer and attention gate.
|
|
34
|
+
head_dims : sequence of int, default=(256, 128)
|
|
35
|
+
Widths of the hidden layers between the gated representation and
|
|
36
|
+
the output logits.
|
|
37
|
+
use_attention : bool, default=True
|
|
38
|
+
If ``True``, apply a sigmoid-gated element-wise attention on the
|
|
39
|
+
projected features. If ``False``, the model reduces to a plain
|
|
40
|
+
MLP of the same depth.
|
|
41
|
+
dropout_high : float, default=0.3
|
|
42
|
+
Dropout applied after the projection and the first dense layer.
|
|
43
|
+
dropout_low : float, default=0.2
|
|
44
|
+
Dropout applied before the output logits.
|
|
45
|
+
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
last_attention : torch.Tensor or None
|
|
49
|
+
Attention weights from the most recent forward pass
|
|
50
|
+
(``(batch, hidden_dim)``). ``None`` when ``use_attention=False``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
input_dim: int,
|
|
56
|
+
n_classes: int = 2,
|
|
57
|
+
hidden_dim: int = 512,
|
|
58
|
+
head_dims: tuple[int, ...] = (256, 128),
|
|
59
|
+
use_attention: bool = True,
|
|
60
|
+
dropout_high: float = 0.3,
|
|
61
|
+
dropout_low: float = 0.2,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.use_attention = use_attention
|
|
65
|
+
self.hidden_dim = hidden_dim
|
|
66
|
+
|
|
67
|
+
self.proj = nn.Sequential(
|
|
68
|
+
nn.Linear(input_dim, hidden_dim),
|
|
69
|
+
nn.BatchNorm1d(hidden_dim),
|
|
70
|
+
nn.ReLU(),
|
|
71
|
+
nn.Dropout(dropout_high),
|
|
72
|
+
)
|
|
73
|
+
if use_attention:
|
|
74
|
+
self.attn: nn.Module = nn.Sequential(
|
|
75
|
+
nn.Linear(hidden_dim, hidden_dim), nn.Sigmoid()
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
self.attn = nn.Identity()
|
|
79
|
+
|
|
80
|
+
head_layers: list[nn.Module] = []
|
|
81
|
+
prev = hidden_dim
|
|
82
|
+
for i, width in enumerate(head_dims):
|
|
83
|
+
head_layers += [
|
|
84
|
+
nn.Linear(prev, width),
|
|
85
|
+
nn.BatchNorm1d(width),
|
|
86
|
+
nn.ReLU(),
|
|
87
|
+
nn.Dropout(dropout_high if i == 0 else dropout_low),
|
|
88
|
+
]
|
|
89
|
+
prev = width
|
|
90
|
+
head_layers.append(nn.Linear(prev, n_classes))
|
|
91
|
+
self.head = nn.Sequential(*head_layers)
|
|
92
|
+
|
|
93
|
+
self.last_attention: torch.Tensor | None = None
|
|
94
|
+
|
|
95
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
96
|
+
"""Map ``(batch, input_dim)`` to ``(batch, n_classes)`` logits."""
|
|
97
|
+
projected = self.proj(x)
|
|
98
|
+
if self.use_attention:
|
|
99
|
+
weights = self.attn(projected)
|
|
100
|
+
self.last_attention = weights.detach()
|
|
101
|
+
gated = projected * weights
|
|
102
|
+
else:
|
|
103
|
+
self.last_attention = None
|
|
104
|
+
gated = projected
|
|
105
|
+
return self.head(gated)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MaldiMLPClassifier(BaseSpectralClassifier):
|
|
109
|
+
"""sklearn-compatible MLP classifier with optional attention gating.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
hidden_dim : int, default=512
|
|
114
|
+
Width of the projection and attention gate.
|
|
115
|
+
head_dims : sequence of int, default=(256, 128)
|
|
116
|
+
Widths of the hidden layers of the classification head.
|
|
117
|
+
use_attention : bool, default=True
|
|
118
|
+
Toggle the sigmoid-gated attention. When ``False``, the model
|
|
119
|
+
is a plain MLP of the same depth.
|
|
120
|
+
dropout_high : float, default=0.3
|
|
121
|
+
Dropout after the projection and first head layer.
|
|
122
|
+
dropout_low : float, default=0.2
|
|
123
|
+
Dropout before the output logits.
|
|
124
|
+
**kwargs
|
|
125
|
+
Forwarded to :class:`~maldideepkit.base.classifier.BaseSpectralClassifier`:
|
|
126
|
+
``input_dim``, ``n_classes``, ``learning_rate``, ``batch_size``,
|
|
127
|
+
``epochs``, ``early_stopping_patience``, ``val_fraction``,
|
|
128
|
+
``standardize``, ``class_weight``, ``device``, ``random_state``,
|
|
129
|
+
``verbose``.
|
|
130
|
+
|
|
131
|
+
Attributes
|
|
132
|
+
----------
|
|
133
|
+
attention_weights_ : ndarray or None
|
|
134
|
+
Attention weights from the last :meth:`fit` or :meth:`predict`
|
|
135
|
+
forward pass. Shape ``(n_samples_last_call, hidden_dim)``. Set to
|
|
136
|
+
``None`` when ``use_attention=False``.
|
|
137
|
+
|
|
138
|
+
Examples
|
|
139
|
+
--------
|
|
140
|
+
>>> import numpy as np
|
|
141
|
+
>>> from maldideepkit import MaldiMLPClassifier
|
|
142
|
+
>>> rng = np.random.default_rng(0)
|
|
143
|
+
>>> X = rng.standard_normal((64, 256)).astype("float32")
|
|
144
|
+
>>> y = rng.integers(0, 2, size=64)
|
|
145
|
+
>>> clf = MaldiMLPClassifier(epochs=2, batch_size=16, random_state=0).fit(X, y)
|
|
146
|
+
>>> clf.predict(X).shape
|
|
147
|
+
(64,)
|
|
148
|
+
>>> weights = clf.get_attention_weights(X[:4])
|
|
149
|
+
>>> weights.shape
|
|
150
|
+
(4, 512)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
input_dim: int | None = None,
|
|
156
|
+
n_classes: int = 2,
|
|
157
|
+
hidden_dim: int = 512,
|
|
158
|
+
head_dims: tuple[int, ...] = (256, 128),
|
|
159
|
+
use_attention: bool = True,
|
|
160
|
+
dropout_high: float = 0.3,
|
|
161
|
+
dropout_low: float = 0.2,
|
|
162
|
+
learning_rate: float = 1e-3,
|
|
163
|
+
weight_decay: float = 0.0,
|
|
164
|
+
grad_clip_norm: float | None = None,
|
|
165
|
+
label_smoothing: float = 0.0,
|
|
166
|
+
loss: str = "cross_entropy",
|
|
167
|
+
focal_gamma: float = 2.0,
|
|
168
|
+
use_amp: bool = False,
|
|
169
|
+
swa_start_epoch: int | None = None,
|
|
170
|
+
tune_threshold: bool = False,
|
|
171
|
+
threshold_metric: str = "balanced_accuracy",
|
|
172
|
+
calibrate_temperature: bool = False,
|
|
173
|
+
min_val_auroc_for_threshold_tune: float = 0.6,
|
|
174
|
+
use_sam: bool = False,
|
|
175
|
+
sam_rho: float = 0.05,
|
|
176
|
+
batch_size: int = 32,
|
|
177
|
+
epochs: int = 100,
|
|
178
|
+
early_stopping_patience: int = 10,
|
|
179
|
+
val_fraction: float = 0.1,
|
|
180
|
+
warmup_epochs: int = 0,
|
|
181
|
+
standardize: bool = False,
|
|
182
|
+
input_transform: str | None = None,
|
|
183
|
+
warping: Any | None = None,
|
|
184
|
+
metrics_log_path: str | Path | None = None,
|
|
185
|
+
track_train_metrics: bool = False,
|
|
186
|
+
augment: Any | None = None,
|
|
187
|
+
mixup_alpha: float = 0.0,
|
|
188
|
+
cutmix_alpha: float = 0.0,
|
|
189
|
+
ema_decay: float | None = None,
|
|
190
|
+
retry_on_val_auroc_below: float | None = None,
|
|
191
|
+
max_retries: int = 2,
|
|
192
|
+
class_weight: str | np.ndarray | list | None = None,
|
|
193
|
+
device: str | torch.device = "auto",
|
|
194
|
+
random_state: int = 0,
|
|
195
|
+
verbose: bool = False,
|
|
196
|
+
) -> None:
|
|
197
|
+
super().__init__(
|
|
198
|
+
input_dim=input_dim,
|
|
199
|
+
n_classes=n_classes,
|
|
200
|
+
learning_rate=learning_rate,
|
|
201
|
+
weight_decay=weight_decay,
|
|
202
|
+
grad_clip_norm=grad_clip_norm,
|
|
203
|
+
label_smoothing=label_smoothing,
|
|
204
|
+
loss=loss,
|
|
205
|
+
focal_gamma=focal_gamma,
|
|
206
|
+
use_amp=use_amp,
|
|
207
|
+
swa_start_epoch=swa_start_epoch,
|
|
208
|
+
tune_threshold=tune_threshold,
|
|
209
|
+
threshold_metric=threshold_metric,
|
|
210
|
+
calibrate_temperature=calibrate_temperature,
|
|
211
|
+
min_val_auroc_for_threshold_tune=min_val_auroc_for_threshold_tune,
|
|
212
|
+
use_sam=use_sam,
|
|
213
|
+
sam_rho=sam_rho,
|
|
214
|
+
batch_size=batch_size,
|
|
215
|
+
epochs=epochs,
|
|
216
|
+
early_stopping_patience=early_stopping_patience,
|
|
217
|
+
val_fraction=val_fraction,
|
|
218
|
+
warmup_epochs=warmup_epochs,
|
|
219
|
+
standardize=standardize,
|
|
220
|
+
input_transform=input_transform,
|
|
221
|
+
warping=warping,
|
|
222
|
+
metrics_log_path=metrics_log_path,
|
|
223
|
+
track_train_metrics=track_train_metrics,
|
|
224
|
+
augment=augment,
|
|
225
|
+
mixup_alpha=mixup_alpha,
|
|
226
|
+
cutmix_alpha=cutmix_alpha,
|
|
227
|
+
ema_decay=ema_decay,
|
|
228
|
+
retry_on_val_auroc_below=retry_on_val_auroc_below,
|
|
229
|
+
max_retries=max_retries,
|
|
230
|
+
class_weight=class_weight,
|
|
231
|
+
device=device,
|
|
232
|
+
random_state=random_state,
|
|
233
|
+
verbose=verbose,
|
|
234
|
+
)
|
|
235
|
+
self.hidden_dim = hidden_dim
|
|
236
|
+
self.head_dims = head_dims
|
|
237
|
+
self.use_attention = use_attention
|
|
238
|
+
self.dropout_high = dropout_high
|
|
239
|
+
self.dropout_low = dropout_low
|
|
240
|
+
self.attention_weights_: np.ndarray | None = None
|
|
241
|
+
|
|
242
|
+
def _build_model(self) -> nn.Module:
|
|
243
|
+
return SpectralAttentionMLP(
|
|
244
|
+
input_dim=self.input_dim_,
|
|
245
|
+
n_classes=self.n_classes_,
|
|
246
|
+
hidden_dim=int(self.hidden_dim),
|
|
247
|
+
head_dims=tuple(self.head_dims),
|
|
248
|
+
use_attention=bool(self.use_attention),
|
|
249
|
+
dropout_high=float(self.dropout_high),
|
|
250
|
+
dropout_low=float(self.dropout_low),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _forward_logits(self, X: Any) -> np.ndarray:
|
|
254
|
+
logits = super()._forward_logits(X)
|
|
255
|
+
if self.use_attention and self.model_.last_attention is not None:
|
|
256
|
+
self.attention_weights_ = self.model_.last_attention.detach().cpu().numpy()
|
|
257
|
+
else:
|
|
258
|
+
self.attention_weights_ = None
|
|
259
|
+
return logits
|
|
260
|
+
|
|
261
|
+
def fit(self, X: Any, y: Any) -> MaldiMLPClassifier: # type: ignore[override]
|
|
262
|
+
"""Fit the model and cache attention weights from the final batch.
|
|
263
|
+
|
|
264
|
+
See :meth:`BaseSpectralClassifier.fit` for shared parameters.
|
|
265
|
+
"""
|
|
266
|
+
super().fit(X, y)
|
|
267
|
+
if self.use_attention:
|
|
268
|
+
X_np = _to_numpy(X)
|
|
269
|
+
tail = X_np[: min(len(X_np), 64)]
|
|
270
|
+
self._forward_logits(tail)
|
|
271
|
+
else:
|
|
272
|
+
self.attention_weights_ = None
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def get_attention_weights(self, X: Any) -> np.ndarray:
|
|
276
|
+
"""Return attention weights for ``X`` of shape ``(len(X), hidden_dim)``.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
X : array-like or MaldiSet of shape (n_samples, n_bins)
|
|
281
|
+
Spectra to inspect. Must match ``input_dim_``.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
ndarray of shape (n_samples, hidden_dim)
|
|
286
|
+
Sigmoid-gated attention weights.
|
|
287
|
+
|
|
288
|
+
Raises
|
|
289
|
+
------
|
|
290
|
+
RuntimeError
|
|
291
|
+
If the classifier was built with ``use_attention=False``.
|
|
292
|
+
"""
|
|
293
|
+
check_is_fitted(self, "model_")
|
|
294
|
+
if not self.use_attention:
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
"get_attention_weights is only available when use_attention=True."
|
|
297
|
+
)
|
|
298
|
+
self._forward_logits(X)
|
|
299
|
+
if self.attention_weights_ is None:
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
"Attention weights were not captured during forward; "
|
|
302
|
+
"ensure the model was built with use_attention=True."
|
|
303
|
+
)
|
|
304
|
+
return self.attention_weights_
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def from_spectrum(
|
|
308
|
+
cls, bin_width: int, input_dim: int, **overrides
|
|
309
|
+
) -> "MaldiMLPClassifier":
|
|
310
|
+
"""Construct a classifier for a given ``(bin_width, input_dim)`` layout.
|
|
311
|
+
|
|
312
|
+
The MLP is architecturally scale-agnostic, so this factory
|
|
313
|
+
only forwards ``input_dim`` and any ``**overrides``. Provided
|
|
314
|
+
for API symmetry with the other classifiers.
|
|
315
|
+
"""
|
|
316
|
+
del bin_width
|
|
317
|
+
kwargs: dict[str, Any] = {"input_dim": input_dim}
|
|
318
|
+
kwargs.update(overrides)
|
|
319
|
+
return cls(**kwargs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Data-augmentation utilities for binned MALDI-TOF spectra.
|
|
2
|
+
|
|
3
|
+
All augmentations are callables that transform a training-batch tensor
|
|
4
|
+
of shape ``(batch, n_bins)`` and return a tensor of the same shape.
|
|
5
|
+
Wire them into :class:`~maldideepkit.BaseSpectralClassifier` via the
|
|
6
|
+
``augment=`` kwarg; they apply to training batches only and are
|
|
7
|
+
bypassed during validation and inference.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .mixing import apply_cutmix, apply_mixup, to_one_hot
|
|
13
|
+
from .spectra import SpectrumAugment
|
|
14
|
+
|
|
15
|
+
__all__ = ["SpectrumAugment", "apply_cutmix", "apply_mixup", "to_one_hot"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""MixUp and CutMix for 1-D binned MALDI-TOF spectra.
|
|
2
|
+
|
|
3
|
+
Both transforms operate on a batch of features ``x`` with shape
|
|
4
|
+
``(batch, n_bins)`` and one-hot targets ``y_oh`` with shape
|
|
5
|
+
``(batch, n_classes)``, and return a mixed ``(x, y_soft)`` pair.
|
|
6
|
+
|
|
7
|
+
- MixUp: ``x = lam * x_i + (1 - lam) * x_j``.
|
|
8
|
+
- CutMix: splice a contiguous m/z window from a shuffled sample
|
|
9
|
+
into the original; labels mixed by window fraction.
|
|
10
|
+
|
|
11
|
+
Both draw the mix coefficient from ``Beta(alpha, alpha)``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn.functional as F
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_one_hot(y: torch.Tensor, n_classes: int) -> torch.Tensor:
|
|
22
|
+
"""Return ``y`` as a float one-hot tensor of shape ``(batch, n_classes)``."""
|
|
23
|
+
return F.one_hot(y.long(), num_classes=n_classes).to(dtype=torch.float32)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sample_beta(alpha: float, generator: torch.Generator | None = None) -> float:
|
|
27
|
+
"""Draw a single ``Beta(alpha, alpha)`` sample."""
|
|
28
|
+
if generator is None:
|
|
29
|
+
return float(np.random.beta(alpha, alpha))
|
|
30
|
+
a = torch.tensor([float(alpha)], dtype=torch.float64)
|
|
31
|
+
x = torch._standard_gamma(a, generator=generator)
|
|
32
|
+
y = torch._standard_gamma(a, generator=generator)
|
|
33
|
+
return float((x / (x + y)).item())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_mixup(
|
|
37
|
+
x: torch.Tensor,
|
|
38
|
+
y_oh: torch.Tensor,
|
|
39
|
+
alpha: float,
|
|
40
|
+
generator: torch.Generator | None = None,
|
|
41
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
42
|
+
"""Mixup: convex-combine two random permutations of the batch.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
x : torch.Tensor
|
|
47
|
+
Feature tensor of shape ``(batch, n_bins)``.
|
|
48
|
+
y_oh : torch.Tensor
|
|
49
|
+
One-hot (or soft) target tensor of shape ``(batch, n_classes)``.
|
|
50
|
+
alpha : float
|
|
51
|
+
Beta-distribution parameter (``Beta(alpha, alpha)``). Typical
|
|
52
|
+
values 0.1-0.4 for tabular-ish inputs. Must be ``> 0``.
|
|
53
|
+
generator : torch.Generator or None, default=None
|
|
54
|
+
Seeded RNG for reproducibility.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
tuple of torch.Tensor
|
|
59
|
+
``(x_mixed, y_mixed)`` with the same shapes as the inputs.
|
|
60
|
+
"""
|
|
61
|
+
if alpha <= 0:
|
|
62
|
+
raise ValueError(f"mixup alpha must be > 0; got {alpha!r}.")
|
|
63
|
+
lam = _sample_beta(alpha, generator)
|
|
64
|
+
perm = torch.randperm(x.shape[0], generator=generator).to(x.device)
|
|
65
|
+
x_mixed = lam * x + (1.0 - lam) * x[perm]
|
|
66
|
+
y_mixed = lam * y_oh + (1.0 - lam) * y_oh[perm]
|
|
67
|
+
return x_mixed, y_mixed
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def apply_cutmix(
|
|
71
|
+
x: torch.Tensor,
|
|
72
|
+
y_oh: torch.Tensor,
|
|
73
|
+
alpha: float,
|
|
74
|
+
generator: torch.Generator | None = None,
|
|
75
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
76
|
+
"""CutMix on 1-D spectra: splice a contiguous m/z window.
|
|
77
|
+
|
|
78
|
+
A window of length ``w = round(n_bins * (1 - lam))`` is drawn
|
|
79
|
+
uniformly along the m/z axis and copied from the shuffled sample
|
|
80
|
+
into the original. Labels are mixed by the window fraction.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
x : torch.Tensor
|
|
85
|
+
Feature tensor of shape ``(batch, n_bins)``.
|
|
86
|
+
y_oh : torch.Tensor
|
|
87
|
+
One-hot (or soft) target tensor of shape ``(batch, n_classes)``.
|
|
88
|
+
alpha : float
|
|
89
|
+
Beta-distribution parameter (``Beta(alpha, alpha)``). Typical
|
|
90
|
+
value 1.0 (uniform over window fractions). Must be ``> 0``.
|
|
91
|
+
generator : torch.Generator or None, default=None
|
|
92
|
+
Seeded RNG for reproducibility.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
tuple of torch.Tensor
|
|
97
|
+
``(x_mixed, y_mixed)`` with the same shapes as the inputs.
|
|
98
|
+
"""
|
|
99
|
+
if alpha <= 0:
|
|
100
|
+
raise ValueError(f"cutmix alpha must be > 0; got {alpha!r}.")
|
|
101
|
+
batch, n_bins = x.shape
|
|
102
|
+
lam = _sample_beta(alpha, generator)
|
|
103
|
+
window = int(round(n_bins * (1.0 - lam)))
|
|
104
|
+
window = max(0, min(window, n_bins))
|
|
105
|
+
if window == 0:
|
|
106
|
+
return x.clone(), y_oh.clone()
|
|
107
|
+
start = int(torch.randint(0, n_bins - window + 1, (1,), generator=generator).item())
|
|
108
|
+
perm = torch.randperm(batch, generator=generator).to(x.device)
|
|
109
|
+
x_mixed = x.clone()
|
|
110
|
+
x_mixed[:, start : start + window] = x[perm][:, start : start + window]
|
|
111
|
+
effective_lam = 1.0 - window / n_bins
|
|
112
|
+
y_mixed = effective_lam * y_oh + (1.0 - effective_lam) * y_oh[perm]
|
|
113
|
+
return x_mixed, y_mixed
|