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.
@@ -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
@@ -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,5 @@
1
+ """Attention-based MLP classifier for MALDI-TOF spectra."""
2
+
3
+ from .mlp import MaldiMLPClassifier, SpectralAttentionMLP
4
+
5
+ __all__ = ["MaldiMLPClassifier", "SpectralAttentionMLP"]
@@ -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