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,380 @@
1
+ """1-D ResNet classifier for binned MALDI-TOF spectra.
2
+
3
+ ResNet-18 residual-block template adapted to 1-D spectral input: a
4
+ stem Conv1D, four stages of BasicBlock1D pairs with strided
5
+ downsampling between stages, global average pooling, and a single
6
+ linear head.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import torch
16
+ from torch import nn
17
+
18
+ from .._bin_scaling import scale_odd_kernel
19
+ from ..base.classifier import BaseSpectralClassifier
20
+
21
+
22
+ class BasicBlock1D(nn.Module):
23
+ """Two Conv1D layers with a residual shortcut.
24
+
25
+ Parameters
26
+ ----------
27
+ in_channels : int
28
+ Number of input channels.
29
+ out_channels : int
30
+ Number of output channels.
31
+ stride : int, default=1
32
+ Stride of the first Conv1D. A stride ``!= 1`` (or a channel
33
+ mismatch) triggers a 1x1 projection on the shortcut path.
34
+ kernel_size : int, default=3
35
+ Kernel size of both Conv1D layers.
36
+ """
37
+
38
+ expansion = 1
39
+
40
+ def __init__(
41
+ self,
42
+ in_channels: int,
43
+ out_channels: int,
44
+ stride: int = 1,
45
+ kernel_size: int = 3,
46
+ ) -> None:
47
+ super().__init__()
48
+ padding = kernel_size // 2
49
+ self.conv1 = nn.Conv1d(
50
+ in_channels,
51
+ out_channels,
52
+ kernel_size=kernel_size,
53
+ stride=stride,
54
+ padding=padding,
55
+ bias=False,
56
+ )
57
+ self.bn1 = nn.BatchNorm1d(out_channels)
58
+ self.conv2 = nn.Conv1d(
59
+ out_channels,
60
+ out_channels,
61
+ kernel_size=kernel_size,
62
+ stride=1,
63
+ padding=padding,
64
+ bias=False,
65
+ )
66
+ self.bn2 = nn.BatchNorm1d(out_channels)
67
+ self.relu = nn.ReLU(inplace=True)
68
+
69
+ if stride != 1 or in_channels != out_channels:
70
+ self.shortcut: nn.Module = nn.Sequential(
71
+ nn.Conv1d(
72
+ in_channels,
73
+ out_channels,
74
+ kernel_size=1,
75
+ stride=stride,
76
+ bias=False,
77
+ ),
78
+ nn.BatchNorm1d(out_channels),
79
+ )
80
+ else:
81
+ self.shortcut = nn.Identity()
82
+
83
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
84
+ """Apply the residual block to a ``(N, C, L)`` activation tensor."""
85
+ identity = self.shortcut(x)
86
+ out = self.relu(self.bn1(self.conv1(x)))
87
+ out = self.bn2(self.conv2(out))
88
+ out = out + identity
89
+ return self.relu(out)
90
+
91
+
92
+ class SpectralResNet1D(nn.Module):
93
+ """1-D ResNet-18 style backbone with a linear classification head.
94
+
95
+ Parameters
96
+ ----------
97
+ input_dim : int
98
+ Number of input bins.
99
+ n_classes : int, default=2
100
+ Number of output logits.
101
+ stem_channels : int, default=32
102
+ Output channels of the initial ``Conv1d`` + pool stem.
103
+ stage_channels : sequence of int, default=(64, 128, 256, 512)
104
+ Output channels of each stage. The first stage keeps the stem
105
+ resolution; later stages downsample by 2.
106
+ blocks_per_stage : sequence of int, default=(2, 2, 2, 2)
107
+ Number of BasicBlock1D pairs per stage.
108
+ stem_kernel_size : int, default=7
109
+ Kernel size of the stem Conv1D.
110
+ stem_stride : int, default=1
111
+ Stride of the stem Conv1D. Defaults to ``1`` (vs literal
112
+ ResNet-18's ``2``) to preserve peak-scale features through the
113
+ first stage on ~6000-bin spectra.
114
+ block_kernel_size : int, default=7
115
+ Kernel size inside every :class:`BasicBlock1D`. Widened from
116
+ ResNet-18's 3 to give each block enough local context for
117
+ peak-scale features.
118
+ use_stem_pool : bool, default=False
119
+ If ``True``, append the literal ResNet-18 ``MaxPool1d(kernel=3,
120
+ stride=2)`` after the stem Conv. Defaults to ``False`` because
121
+ the combined stem-Conv(stride=2) + MaxPool(stride=2) initial
122
+ 4x downsampling collapses peak-scale features. Set to ``True``
123
+ to reproduce the literal ResNet-18 backbone.
124
+ dropout : float, default=0.2
125
+ Dropout applied after global average pooling.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ input_dim: int,
131
+ n_classes: int = 2,
132
+ stem_channels: int = 32,
133
+ stage_channels: tuple[int, ...] = (64, 128, 256, 512),
134
+ blocks_per_stage: tuple[int, ...] = (2, 2, 2, 2),
135
+ stem_kernel_size: int = 7,
136
+ stem_stride: int = 1,
137
+ block_kernel_size: int = 7,
138
+ use_stem_pool: bool = False,
139
+ dropout: float = 0.2,
140
+ ) -> None:
141
+ super().__init__()
142
+ if len(stage_channels) != len(blocks_per_stage):
143
+ raise ValueError(
144
+ "stage_channels and blocks_per_stage must have the same length."
145
+ )
146
+ stem_layers: list[nn.Module] = [
147
+ nn.Conv1d(
148
+ 1,
149
+ stem_channels,
150
+ kernel_size=stem_kernel_size,
151
+ stride=stem_stride,
152
+ padding=stem_kernel_size // 2,
153
+ bias=False,
154
+ ),
155
+ nn.BatchNorm1d(stem_channels),
156
+ nn.ReLU(inplace=True),
157
+ ]
158
+ if use_stem_pool:
159
+ stem_layers.append(nn.MaxPool1d(kernel_size=3, stride=2, padding=1))
160
+ self.stem = nn.Sequential(*stem_layers)
161
+
162
+ stages: list[nn.Module] = []
163
+ prev = stem_channels
164
+ for i, (ch, n_blocks) in enumerate(
165
+ zip(stage_channels, blocks_per_stage, strict=True)
166
+ ):
167
+ stride = 1 if i == 0 else 2
168
+ layer = [
169
+ BasicBlock1D(prev, ch, stride=stride, kernel_size=block_kernel_size)
170
+ ]
171
+ layer += [
172
+ BasicBlock1D(ch, ch, stride=1, kernel_size=block_kernel_size)
173
+ for _ in range(n_blocks - 1)
174
+ ]
175
+ stages.append(nn.Sequential(*layer))
176
+ prev = ch
177
+ self.stages = nn.Sequential(*stages)
178
+
179
+ self.gap = nn.AdaptiveAvgPool1d(1)
180
+ self.dropout = nn.Dropout(dropout)
181
+ self.fc = nn.Linear(prev, n_classes)
182
+ self.input_dim = input_dim
183
+
184
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
185
+ """Map ``(batch, input_dim)`` to ``(batch, n_classes)`` logits."""
186
+ x = x.unsqueeze(1)
187
+ x = self.stem(x)
188
+ x = self.stages(x)
189
+ x = self.gap(x).squeeze(-1)
190
+ x = self.dropout(x)
191
+ return self.fc(x)
192
+
193
+
194
+ class MaldiResNetClassifier(BaseSpectralClassifier):
195
+ """sklearn-compatible 1-D ResNet classifier for MALDI-TOF spectra.
196
+
197
+ Parameters
198
+ ----------
199
+ stem_channels : int, default=32
200
+ Output channels of the stem.
201
+ stage_channels : sequence of int, default=(64, 128, 256, 512)
202
+ Output channels of each residual stage.
203
+ blocks_per_stage : sequence of int, default=(2, 2, 2, 2)
204
+ Number of residual blocks per stage (ResNet-18 topology).
205
+ stem_kernel_size : int, default=7
206
+ Kernel size of the stem Conv1D. Calibrated for ``bin_width=3``;
207
+ :meth:`from_spectrum` scales it for other bin widths.
208
+ stem_stride : int, default=1
209
+ Stride of the stem Conv1D. Defaults to ``1`` so the first
210
+ residual stage sees full-resolution input.
211
+ block_kernel_size : int, default=7
212
+ Kernel size inside every :class:`BasicBlock1D`. Widened from
213
+ ResNet-18's 3 so each block has enough local context for
214
+ peak-scale features.
215
+ use_stem_pool : bool, default=False
216
+ If ``True``, append the literal ResNet-18 MaxPool after the
217
+ stem Conv. Defaults to ``False`` because the combined
218
+ stem(stride=2) + MaxPool(stride=2) 4x downsampling is too
219
+ aggressive for MALDI-TOF peak structure. Set to ``True`` to
220
+ reproduce the literal ResNet-18 backbone.
221
+ dropout : float, default=0.2
222
+ Dropout before the final linear layer.
223
+ **kwargs
224
+ Forwarded to :class:`~maldideepkit.base.classifier.BaseSpectralClassifier`.
225
+
226
+ Notes
227
+ -----
228
+ Defaults deviate from literal ResNet-18 (He et al., 2016) in three
229
+ ways for MALDI-TOF: ``stem_stride=1`` (was 2),
230
+ ``block_kernel_size=7`` (was 3), and ``use_stem_pool=False`` (was
231
+ True). The literal backbone is reproducible via ``stem_stride=2,
232
+ block_kernel_size=3, use_stem_pool=True``.
233
+
234
+ The final head is a ``Linear(stage_channels[-1], n_classes)``
235
+ after global average pooling, so the parameter count is
236
+ **independent of ``input_dim``**.
237
+
238
+ Examples
239
+ --------
240
+ >>> import numpy as np
241
+ >>> from maldideepkit import MaldiResNetClassifier
242
+ >>> rng = np.random.default_rng(0)
243
+ >>> X = rng.standard_normal((32, 512)).astype("float32")
244
+ >>> y = rng.integers(0, 2, size=32)
245
+ >>> clf = MaldiResNetClassifier(
246
+ ... epochs=2, batch_size=8, stage_channels=(16, 32),
247
+ ... blocks_per_stage=(1, 1), random_state=0
248
+ ... ).fit(X, y)
249
+ >>> clf.predict(X).shape
250
+ (32,)
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ input_dim: int | None = None,
256
+ n_classes: int = 2,
257
+ stem_channels: int = 32,
258
+ stage_channels: tuple[int, ...] = (64, 128, 256, 512),
259
+ blocks_per_stage: tuple[int, ...] = (2, 2, 2, 2),
260
+ stem_kernel_size: int = 7,
261
+ stem_stride: int = 1,
262
+ block_kernel_size: int = 7,
263
+ use_stem_pool: bool = False,
264
+ dropout: float = 0.2,
265
+ learning_rate: float = 1e-3,
266
+ weight_decay: float = 1e-4,
267
+ grad_clip_norm: float | None = 1.0,
268
+ label_smoothing: float = 0.0,
269
+ loss: str = "cross_entropy",
270
+ focal_gamma: float = 2.0,
271
+ use_amp: bool = False,
272
+ swa_start_epoch: int | None = None,
273
+ tune_threshold: bool = False,
274
+ threshold_metric: str = "balanced_accuracy",
275
+ calibrate_temperature: bool = False,
276
+ min_val_auroc_for_threshold_tune: float = 0.6,
277
+ use_sam: bool = False,
278
+ sam_rho: float = 0.05,
279
+ batch_size: int = 32,
280
+ epochs: int = 100,
281
+ early_stopping_patience: int = 10,
282
+ val_fraction: float = 0.1,
283
+ warmup_epochs: int = 5,
284
+ standardize: bool = False,
285
+ input_transform: str | None = "log1p",
286
+ warping: Any | None = None,
287
+ metrics_log_path: str | Path | None = None,
288
+ track_train_metrics: bool = False,
289
+ augment: Any | None = None,
290
+ mixup_alpha: float = 0.0,
291
+ cutmix_alpha: float = 0.0,
292
+ ema_decay: float | None = None,
293
+ retry_on_val_auroc_below: float | None = None,
294
+ max_retries: int = 2,
295
+ class_weight: str | np.ndarray | list | None = None,
296
+ device: str | torch.device = "auto",
297
+ random_state: int = 0,
298
+ verbose: bool = False,
299
+ ) -> None:
300
+ super().__init__(
301
+ input_dim=input_dim,
302
+ n_classes=n_classes,
303
+ learning_rate=learning_rate,
304
+ weight_decay=weight_decay,
305
+ grad_clip_norm=grad_clip_norm,
306
+ label_smoothing=label_smoothing,
307
+ loss=loss,
308
+ focal_gamma=focal_gamma,
309
+ use_amp=use_amp,
310
+ swa_start_epoch=swa_start_epoch,
311
+ tune_threshold=tune_threshold,
312
+ threshold_metric=threshold_metric,
313
+ calibrate_temperature=calibrate_temperature,
314
+ min_val_auroc_for_threshold_tune=min_val_auroc_for_threshold_tune,
315
+ use_sam=use_sam,
316
+ sam_rho=sam_rho,
317
+ batch_size=batch_size,
318
+ epochs=epochs,
319
+ early_stopping_patience=early_stopping_patience,
320
+ val_fraction=val_fraction,
321
+ warmup_epochs=warmup_epochs,
322
+ standardize=standardize,
323
+ input_transform=input_transform,
324
+ warping=warping,
325
+ metrics_log_path=metrics_log_path,
326
+ track_train_metrics=track_train_metrics,
327
+ augment=augment,
328
+ mixup_alpha=mixup_alpha,
329
+ cutmix_alpha=cutmix_alpha,
330
+ ema_decay=ema_decay,
331
+ retry_on_val_auroc_below=retry_on_val_auroc_below,
332
+ max_retries=max_retries,
333
+ class_weight=class_weight,
334
+ device=device,
335
+ random_state=random_state,
336
+ verbose=verbose,
337
+ )
338
+ self.stem_channels = stem_channels
339
+ self.stage_channels = stage_channels
340
+ self.blocks_per_stage = blocks_per_stage
341
+ self.stem_kernel_size = stem_kernel_size
342
+ self.stem_stride = stem_stride
343
+ self.block_kernel_size = block_kernel_size
344
+ self.use_stem_pool = use_stem_pool
345
+ self.dropout = dropout
346
+
347
+ def _build_model(self) -> nn.Module:
348
+ return SpectralResNet1D(
349
+ input_dim=self.input_dim_,
350
+ n_classes=self.n_classes_,
351
+ stem_channels=int(self.stem_channels),
352
+ stage_channels=tuple(self.stage_channels),
353
+ blocks_per_stage=tuple(self.blocks_per_stage),
354
+ stem_kernel_size=int(self.stem_kernel_size),
355
+ stem_stride=int(self.stem_stride),
356
+ block_kernel_size=int(self.block_kernel_size),
357
+ use_stem_pool=bool(self.use_stem_pool),
358
+ dropout=float(self.dropout),
359
+ )
360
+
361
+ @classmethod
362
+ def from_spectrum(
363
+ cls, bin_width: int, input_dim: int, **overrides
364
+ ) -> "MaldiResNetClassifier":
365
+ """Construct a peak-friendly ResNet for a given spectrum layout.
366
+
367
+ Scales ``stem_kernel_size`` inversely with ``bin_width``
368
+ relative to the package reference (``bin_width=3``,
369
+ ``stem_kernel_size=7``). Also sets ``stem_stride=1`` and
370
+ ``use_stem_pool=False`` (the class defaults). Any keyword in
371
+ ``**overrides`` wins over the auto-selected values.
372
+ """
373
+ kwargs: dict[str, Any] = {
374
+ "input_dim": input_dim,
375
+ "stem_kernel_size": scale_odd_kernel(bin_width),
376
+ "stem_stride": 1,
377
+ "use_stem_pool": False,
378
+ }
379
+ kwargs.update(overrides)
380
+ return cls(**kwargs)
@@ -0,0 +1,7 @@
1
+ """1-D Vision Transformer (ViT) classifier for binned MALDI-TOF spectra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .transformer import MaldiTransformerClassifier, SpectralTransformer1D
6
+
7
+ __all__ = ["MaldiTransformerClassifier", "SpectralTransformer1D"]