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,1079 @@
|
|
|
1
|
+
"""Abstract base class for sklearn-compatible spectral classifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABCMeta, abstractmethod
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
from sklearn.base import BaseEstimator, ClassifierMixin, clone
|
|
13
|
+
from sklearn.utils.multiclass import unique_labels
|
|
14
|
+
from sklearn.utils.validation import check_is_fitted
|
|
15
|
+
from torch import nn
|
|
16
|
+
from torch.utils.data import DataLoader
|
|
17
|
+
|
|
18
|
+
from ..utils.loss import FocalLoss
|
|
19
|
+
from ..utils.reproducibility import resolve_device, seed_everything
|
|
20
|
+
from ..utils.training import EarlyStopping, train_loop
|
|
21
|
+
from .data import SpectralDataset, _to_numpy, make_loaders
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _serialise_transform_state(state: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
25
|
+
"""Convert numpy arrays inside an input-transform state to lists (JSON-safe)."""
|
|
26
|
+
if state is None:
|
|
27
|
+
return None
|
|
28
|
+
out: dict[str, Any] = {}
|
|
29
|
+
for k, v in state.items():
|
|
30
|
+
if isinstance(v, np.ndarray):
|
|
31
|
+
out[k] = v.tolist()
|
|
32
|
+
else:
|
|
33
|
+
out[k] = v
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _deserialise_transform_state(
|
|
38
|
+
state: dict[str, Any] | None,
|
|
39
|
+
) -> dict[str, Any] | None:
|
|
40
|
+
"""Inverse of :func:`_serialise_transform_state`; numpy-ifies list values."""
|
|
41
|
+
if state is None:
|
|
42
|
+
return None
|
|
43
|
+
out: dict[str, Any] = {}
|
|
44
|
+
for k, v in state.items():
|
|
45
|
+
if k == "mode":
|
|
46
|
+
out[k] = v
|
|
47
|
+
elif isinstance(v, list):
|
|
48
|
+
out[k] = np.asarray(v, dtype=np.float32)
|
|
49
|
+
else:
|
|
50
|
+
out[k] = v
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BaseSpectralClassifier(ClassifierMixin, BaseEstimator, metaclass=ABCMeta): # type: ignore[misc]
|
|
55
|
+
"""Abstract base for all MaldiDeepKit classifiers.
|
|
56
|
+
|
|
57
|
+
Concrete subclasses only need to override :meth:`_build_model`,
|
|
58
|
+
which should return a :class:`torch.nn.Module` that maps an input of
|
|
59
|
+
shape ``(batch, input_dim)`` to logits of shape ``(batch, n_classes)``.
|
|
60
|
+
Everything else (device placement, validation split, early stopping,
|
|
61
|
+
checkpointing, predict / predict_proba, save / load) is provided here.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
input_dim : int or None, default=None
|
|
66
|
+
Number of input bins. If ``None``, inferred from ``X`` at
|
|
67
|
+
:meth:`fit` time and stored as :attr:`input_dim_`.
|
|
68
|
+
n_classes : int, default=2
|
|
69
|
+
Number of output classes. Overwritten with the true number of
|
|
70
|
+
classes found in ``y`` at :meth:`fit` time.
|
|
71
|
+
learning_rate : float, default=1e-3
|
|
72
|
+
Initial learning rate for the optimizer (Adam by default; AdamW
|
|
73
|
+
when ``weight_decay > 0``).
|
|
74
|
+
weight_decay : float, default=0.0
|
|
75
|
+
L2 penalty applied via decoupled weight decay. When ``> 0`` the
|
|
76
|
+
optimizer switches from ``Adam`` to ``AdamW``.
|
|
77
|
+
grad_clip_norm : float or None, default=None
|
|
78
|
+
If set, clip gradient global L2 norm to this value before every
|
|
79
|
+
optimizer step. ``1.0`` is a common default for transformers.
|
|
80
|
+
label_smoothing : float, default=0.0
|
|
81
|
+
Label smoothing factor in ``[0, 1)`` passed to the loss. Applied
|
|
82
|
+
to both cross-entropy and focal-loss paths.
|
|
83
|
+
loss : {"cross_entropy", "focal"}, default="cross_entropy"
|
|
84
|
+
Classification loss. ``"focal"`` uses
|
|
85
|
+
:class:`~maldideepkit.utils.FocalLoss` with ``gamma=focal_gamma``.
|
|
86
|
+
Good for highly imbalanced problems.
|
|
87
|
+
focal_gamma : float, default=2.0
|
|
88
|
+
Focal-loss focusing parameter. Ignored when
|
|
89
|
+
``loss="cross_entropy"``.
|
|
90
|
+
use_amp : bool, default=False
|
|
91
|
+
If ``True`` and the resolved device is CUDA, run forward + loss
|
|
92
|
+
under :func:`torch.autocast` and use :class:`torch.amp.GradScaler`
|
|
93
|
+
for backward. ~2x wall-time speedup on recent NVIDIA GPUs. On CPU
|
|
94
|
+
this is a no-op.
|
|
95
|
+
swa_start_epoch : int or None, default=None
|
|
96
|
+
If set, start Stochastic Weight Averaging at this epoch. The SWA
|
|
97
|
+
average replaces the best-val checkpoint at the end of fit.
|
|
98
|
+
Typical value: 60-80% of ``epochs``.
|
|
99
|
+
tune_threshold : bool, default=False
|
|
100
|
+
(Binary classification only.) After fit, sweep thresholds on
|
|
101
|
+
the validation split and store the one that maximises
|
|
102
|
+
``threshold_metric``. :meth:`predict` uses this threshold
|
|
103
|
+
instead of ``argmax @ 0.5``.
|
|
104
|
+
threshold_metric : {"balanced_accuracy", "f1", "youden"}, default="balanced_accuracy"
|
|
105
|
+
Metric used by ``tune_threshold``.
|
|
106
|
+
calibrate_temperature : bool, default=False
|
|
107
|
+
If ``True``, after fit run LBFGS-based temperature scaling on
|
|
108
|
+
held-out validation logits (Guo et al. 2017). The fitted
|
|
109
|
+
temperature is stored as :attr:`temperature_` and applied in
|
|
110
|
+
:meth:`predict_proba` to sharpen / smooth probabilities
|
|
111
|
+
without changing the argmax.
|
|
112
|
+
min_val_auroc_for_threshold_tune : float, default=0.6
|
|
113
|
+
Binary-classification guardrail on ``tune_threshold=True``: if
|
|
114
|
+
the validation AUROC is below this value, the threshold sweep
|
|
115
|
+
is skipped and ``threshold_`` falls back to ``0.5``. Set to
|
|
116
|
+
``0.0`` to disable.
|
|
117
|
+
use_sam : bool, default=False
|
|
118
|
+
If ``True``, wrap the base optimizer in
|
|
119
|
+
:class:`~maldideepkit.utils.SAMOptimizer` and run the two-step
|
|
120
|
+
Sharpness-Aware Minimization update. Doubles forward / backward
|
|
121
|
+
compute per step; typically helps generalisation on small
|
|
122
|
+
datasets.
|
|
123
|
+
sam_rho : float, default=0.05
|
|
124
|
+
Size of the SAM ascent step. Ignored when ``use_sam=False``.
|
|
125
|
+
batch_size : int, default=32
|
|
126
|
+
Training mini-batch size.
|
|
127
|
+
epochs : int, default=100
|
|
128
|
+
Maximum number of training epochs.
|
|
129
|
+
early_stopping_patience : int, default=10
|
|
130
|
+
Number of epochs without validation-loss improvement before
|
|
131
|
+
training is stopped.
|
|
132
|
+
val_fraction : float, default=0.1
|
|
133
|
+
Fraction of the training data held out for the internal
|
|
134
|
+
validation split.
|
|
135
|
+
warmup_epochs : int, default=0
|
|
136
|
+
If positive, linearly ramp each optimizer param group's learning
|
|
137
|
+
rate from ``0`` to its configured target over the first
|
|
138
|
+
``warmup_epochs`` epochs. Useful for transformer architectures
|
|
139
|
+
that can diverge at full learning rate during the first few steps.
|
|
140
|
+
standardize : bool, default=False
|
|
141
|
+
Shorthand for ``input_transform="standardize"`` (when True) or
|
|
142
|
+
``"none"`` (when False). Kept for backwards compatibility;
|
|
143
|
+
``input_transform`` is the modern interface and wins when
|
|
144
|
+
both are supplied.
|
|
145
|
+
input_transform : str, optional
|
|
146
|
+
One of ``{"none", "standardize", "log1p", "robust",
|
|
147
|
+
"log1p+standardize"}``. Fit on the (warped) training split
|
|
148
|
+
only and stored as :attr:`input_transform_state_`; reapplied
|
|
149
|
+
at :meth:`predict` / :meth:`predict_proba` time.
|
|
150
|
+
warping : sklearn-style transformer, optional
|
|
151
|
+
Spectral alignment / warping transformer applied **before**
|
|
152
|
+
standardization. Fitted on the training split only, then
|
|
153
|
+
used to transform both splits during training and new data
|
|
154
|
+
at :meth:`predict` / :meth:`predict_proba` time. The fitted
|
|
155
|
+
transformer is stored as :attr:`warper_`.
|
|
156
|
+
metrics_log_path : str or Path, optional
|
|
157
|
+
If set, write a per-epoch metrics CSV to this path during
|
|
158
|
+
:meth:`fit`. One row per epoch with columns ``epoch,
|
|
159
|
+
train_loss, val_loss, lr, mean_grad_norm, n_grad_updates``
|
|
160
|
+
(+ ``train_auroc, val_auroc`` when ``track_train_metrics=True``).
|
|
161
|
+
track_train_metrics : bool, default=False
|
|
162
|
+
Only used when ``metrics_log_path`` is set. If ``True``, after
|
|
163
|
+
every epoch run a no-grad forward pass over the full training
|
|
164
|
+
split and record ``train_auroc`` + ``val_auroc`` alongside the
|
|
165
|
+
losses. Adds one extra pass per epoch; binary classification
|
|
166
|
+
only.
|
|
167
|
+
augment : callable, optional
|
|
168
|
+
Per-batch augmentation applied to training batches only. The
|
|
169
|
+
usual choice is :class:`~maldideepkit.augment.SpectrumAugment`.
|
|
170
|
+
mixup_alpha : float, default=0.0
|
|
171
|
+
If positive, apply MixUp augmentation per training batch with a
|
|
172
|
+
Beta(``mixup_alpha``, ``mixup_alpha``) mixing coefficient.
|
|
173
|
+
``0.0`` disables MixUp. Composable with ``cutmix_alpha``.
|
|
174
|
+
cutmix_alpha : float, default=0.0
|
|
175
|
+
If positive, apply CutMix augmentation per training batch with
|
|
176
|
+
a Beta(``cutmix_alpha``, ``cutmix_alpha``) mixing coefficient.
|
|
177
|
+
``0.0`` disables CutMix.
|
|
178
|
+
ema_decay : float or None, default=None
|
|
179
|
+
If set (typically ``0.999``), maintain an exponential moving
|
|
180
|
+
average of model weights during training and use the EMA weights
|
|
181
|
+
at inference time.
|
|
182
|
+
retry_on_val_auroc_below : float or None, default=None
|
|
183
|
+
Binary-classification guardrail. If set and the post-fit
|
|
184
|
+
validation AUROC is below this threshold, retrain with a
|
|
185
|
+
different RNG seed up to ``max_retries`` times. Useful for
|
|
186
|
+
unstable small-data fits.
|
|
187
|
+
max_retries : int, default=2
|
|
188
|
+
Maximum number of automatic refits triggered by
|
|
189
|
+
``retry_on_val_auroc_below``. Ignored when that guardrail is
|
|
190
|
+
unset.
|
|
191
|
+
class_weight : {"balanced", None} or array-like, default=None
|
|
192
|
+
Per-class weights applied to :class:`~torch.nn.CrossEntropyLoss`.
|
|
193
|
+
``"balanced"`` uses ``n_samples / (n_classes * class_count)``.
|
|
194
|
+
device : {"auto", "cpu", "cuda"} or torch.device, default="auto"
|
|
195
|
+
Device used for training and inference.
|
|
196
|
+
random_state : int, default=0
|
|
197
|
+
Seeds Python, NumPy, and PyTorch RNGs and the validation split.
|
|
198
|
+
verbose : bool, default=False
|
|
199
|
+
If ``True``, prints one line per training epoch.
|
|
200
|
+
|
|
201
|
+
Attributes
|
|
202
|
+
----------
|
|
203
|
+
model_ : torch.nn.Module
|
|
204
|
+
The fitted PyTorch model.
|
|
205
|
+
classes_ : ndarray of shape (n_classes,)
|
|
206
|
+
Original class labels seen during :meth:`fit`.
|
|
207
|
+
input_dim_ : int
|
|
208
|
+
Resolved number of input features.
|
|
209
|
+
n_classes_ : int
|
|
210
|
+
Resolved number of classes.
|
|
211
|
+
feature_mean_ : ndarray or None
|
|
212
|
+
Per-feature mean used when ``standardize=True``.
|
|
213
|
+
feature_std_ : ndarray or None
|
|
214
|
+
Per-feature std used when ``standardize=True``.
|
|
215
|
+
n_features_in_ : int
|
|
216
|
+
Number of features seen at :meth:`fit` (sklearn convention).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
input_dim: int | None = None,
|
|
222
|
+
n_classes: int = 2,
|
|
223
|
+
learning_rate: float = 1e-3,
|
|
224
|
+
weight_decay: float = 0.0,
|
|
225
|
+
grad_clip_norm: float | None = None,
|
|
226
|
+
label_smoothing: float = 0.0,
|
|
227
|
+
loss: str = "cross_entropy",
|
|
228
|
+
focal_gamma: float = 2.0,
|
|
229
|
+
use_amp: bool = False,
|
|
230
|
+
swa_start_epoch: int | None = None,
|
|
231
|
+
tune_threshold: bool = False,
|
|
232
|
+
threshold_metric: str = "balanced_accuracy",
|
|
233
|
+
calibrate_temperature: bool = False,
|
|
234
|
+
min_val_auroc_for_threshold_tune: float = 0.6,
|
|
235
|
+
use_sam: bool = False,
|
|
236
|
+
sam_rho: float = 0.05,
|
|
237
|
+
batch_size: int = 32,
|
|
238
|
+
epochs: int = 100,
|
|
239
|
+
early_stopping_patience: int = 10,
|
|
240
|
+
val_fraction: float = 0.1,
|
|
241
|
+
warmup_epochs: int = 0,
|
|
242
|
+
standardize: bool = False,
|
|
243
|
+
input_transform: str | None = None,
|
|
244
|
+
warping: Any | None = None,
|
|
245
|
+
metrics_log_path: str | Path | None = None,
|
|
246
|
+
track_train_metrics: bool = False,
|
|
247
|
+
augment: Callable[[torch.Tensor], torch.Tensor] | None = None,
|
|
248
|
+
mixup_alpha: float = 0.0,
|
|
249
|
+
cutmix_alpha: float = 0.0,
|
|
250
|
+
ema_decay: float | None = None,
|
|
251
|
+
retry_on_val_auroc_below: float | None = None,
|
|
252
|
+
max_retries: int = 2,
|
|
253
|
+
class_weight: str | np.ndarray | list | None = None,
|
|
254
|
+
device: str | torch.device = "auto",
|
|
255
|
+
random_state: int = 0,
|
|
256
|
+
verbose: bool = False,
|
|
257
|
+
) -> None:
|
|
258
|
+
self.input_dim = input_dim
|
|
259
|
+
self.n_classes = n_classes
|
|
260
|
+
self.learning_rate = learning_rate
|
|
261
|
+
self.weight_decay = weight_decay
|
|
262
|
+
self.grad_clip_norm = grad_clip_norm
|
|
263
|
+
self.label_smoothing = label_smoothing
|
|
264
|
+
self.loss = loss
|
|
265
|
+
self.focal_gamma = focal_gamma
|
|
266
|
+
self.use_amp = use_amp
|
|
267
|
+
self.swa_start_epoch = swa_start_epoch
|
|
268
|
+
self.tune_threshold = tune_threshold
|
|
269
|
+
self.threshold_metric = threshold_metric
|
|
270
|
+
self.calibrate_temperature = calibrate_temperature
|
|
271
|
+
self.min_val_auroc_for_threshold_tune = min_val_auroc_for_threshold_tune
|
|
272
|
+
self.use_sam = use_sam
|
|
273
|
+
self.sam_rho = sam_rho
|
|
274
|
+
self.batch_size = batch_size
|
|
275
|
+
self.epochs = epochs
|
|
276
|
+
self.early_stopping_patience = early_stopping_patience
|
|
277
|
+
self.val_fraction = val_fraction
|
|
278
|
+
self.warmup_epochs = warmup_epochs
|
|
279
|
+
self.standardize = standardize
|
|
280
|
+
self.input_transform = input_transform
|
|
281
|
+
self.warping = warping
|
|
282
|
+
self.metrics_log_path = metrics_log_path
|
|
283
|
+
self.track_train_metrics = track_train_metrics
|
|
284
|
+
self.augment = augment
|
|
285
|
+
self.mixup_alpha = mixup_alpha
|
|
286
|
+
self.cutmix_alpha = cutmix_alpha
|
|
287
|
+
self.ema_decay = ema_decay
|
|
288
|
+
self.retry_on_val_auroc_below = retry_on_val_auroc_below
|
|
289
|
+
self.max_retries = max_retries
|
|
290
|
+
self.class_weight = class_weight
|
|
291
|
+
self.device = device
|
|
292
|
+
self.random_state = random_state
|
|
293
|
+
self.verbose = verbose
|
|
294
|
+
|
|
295
|
+
@abstractmethod
|
|
296
|
+
def _build_model(self) -> nn.Module:
|
|
297
|
+
"""Return a fresh :class:`nn.Module` for the current hyperparameters.
|
|
298
|
+
|
|
299
|
+
Implementations should use :attr:`input_dim_` and
|
|
300
|
+
:attr:`n_classes_` rather than the constructor arguments, since
|
|
301
|
+
those are the values resolved at :meth:`fit` time.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def _optimizer_param_groups(self, model: nn.Module) -> list[dict[str, Any]]:
|
|
305
|
+
"""Return parameter groups for the optimizer.
|
|
306
|
+
|
|
307
|
+
Default: a single group containing every parameter of ``model``.
|
|
308
|
+
Override in a subclass to give specific parameters a different
|
|
309
|
+
learning rate or weight decay.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
model : nn.Module
|
|
314
|
+
The freshly built, on-device training model.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
list of dict
|
|
319
|
+
Param-group dicts suitable for :class:`torch.optim.Adam`.
|
|
320
|
+
Groups without an explicit ``lr`` or ``weight_decay``
|
|
321
|
+
inherit the defaults from :meth:`fit`.
|
|
322
|
+
"""
|
|
323
|
+
return [{"params": list(model.parameters())}]
|
|
324
|
+
|
|
325
|
+
def _resolve_device(self) -> torch.device:
|
|
326
|
+
return resolve_device(self.device)
|
|
327
|
+
|
|
328
|
+
def _compute_class_weight(self, y: np.ndarray) -> torch.Tensor | None:
|
|
329
|
+
if self.class_weight is None:
|
|
330
|
+
return None
|
|
331
|
+
if isinstance(self.class_weight, str):
|
|
332
|
+
if self.class_weight != "balanced":
|
|
333
|
+
raise ValueError(
|
|
334
|
+
f"Unknown class_weight={self.class_weight!r}; "
|
|
335
|
+
"use 'balanced', None, or an array."
|
|
336
|
+
)
|
|
337
|
+
counts = np.bincount(y, minlength=self.n_classes_)
|
|
338
|
+
if np.any(counts == 0):
|
|
339
|
+
missing = np.flatnonzero(counts == 0).tolist()
|
|
340
|
+
raise ValueError(
|
|
341
|
+
"class_weight='balanced' requires every class to be "
|
|
342
|
+
f"present in y; missing class indices: {missing}."
|
|
343
|
+
)
|
|
344
|
+
weights = len(y) / (self.n_classes_ * counts)
|
|
345
|
+
return torch.tensor(weights, dtype=torch.float32)
|
|
346
|
+
weights = np.asarray(self.class_weight, dtype=np.float32)
|
|
347
|
+
if weights.shape != (self.n_classes_,):
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"class_weight has shape {weights.shape}, "
|
|
350
|
+
f"expected ({self.n_classes_},)."
|
|
351
|
+
)
|
|
352
|
+
return torch.from_numpy(weights)
|
|
353
|
+
|
|
354
|
+
def _build_criterion(self, class_weight: torch.Tensor | None) -> nn.Module:
|
|
355
|
+
if self.loss == "cross_entropy":
|
|
356
|
+
return nn.CrossEntropyLoss(
|
|
357
|
+
weight=class_weight,
|
|
358
|
+
label_smoothing=float(self.label_smoothing),
|
|
359
|
+
)
|
|
360
|
+
if self.loss == "focal":
|
|
361
|
+
return FocalLoss(
|
|
362
|
+
weight=class_weight,
|
|
363
|
+
gamma=float(self.focal_gamma),
|
|
364
|
+
label_smoothing=float(self.label_smoothing),
|
|
365
|
+
)
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f"Unknown loss={self.loss!r}; expected 'cross_entropy' or 'focal'."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _make_mix_generator(self) -> torch.Generator | None:
|
|
371
|
+
if float(self.mixup_alpha) <= 0 and float(self.cutmix_alpha) <= 0:
|
|
372
|
+
return None
|
|
373
|
+
gen = torch.Generator()
|
|
374
|
+
gen.manual_seed(int(self.random_state))
|
|
375
|
+
return gen
|
|
376
|
+
|
|
377
|
+
def _prepare_inputs(self, X: Any, y: Any) -> tuple[np.ndarray, np.ndarray]:
|
|
378
|
+
X_np = _to_numpy(X)
|
|
379
|
+
if hasattr(y, "to_numpy"):
|
|
380
|
+
y = y.to_numpy()
|
|
381
|
+
y_np = np.asarray(y).ravel()
|
|
382
|
+
if X_np.shape[0] != y_np.shape[0]:
|
|
383
|
+
raise ValueError(f"X has {X_np.shape[0]} rows but y has {y_np.shape[0]}.")
|
|
384
|
+
self.classes_ = unique_labels(y_np)
|
|
385
|
+
self.n_classes_ = int(len(self.classes_))
|
|
386
|
+
if self.n_classes_ < 2:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"{type(self).__name__} needs at least 2 classes in y; "
|
|
389
|
+
f"got {self.n_classes_}."
|
|
390
|
+
)
|
|
391
|
+
self.input_dim_ = (
|
|
392
|
+
int(X_np.shape[1]) if self.input_dim is None else int(self.input_dim)
|
|
393
|
+
)
|
|
394
|
+
if self.input_dim_ != X_np.shape[1]:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
f"input_dim={self.input_dim_} does not match X.shape[1]={X_np.shape[1]}."
|
|
397
|
+
)
|
|
398
|
+
self.n_features_in_ = self.input_dim_
|
|
399
|
+
y_encoded = np.searchsorted(self.classes_, y_np).astype(np.int64)
|
|
400
|
+
return X_np, y_encoded
|
|
401
|
+
|
|
402
|
+
def fit(self, X: Any, y: Any) -> BaseSpectralClassifier:
|
|
403
|
+
"""Fit the model on ``(X, y)``.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
X : array-like or MaldiSet of shape (n_samples, n_bins)
|
|
408
|
+
Training spectra. NumPy arrays, pandas DataFrames, and
|
|
409
|
+
objects with a DataFrame-like ``.X`` attribute are accepted.
|
|
410
|
+
y : array-like of shape (n_samples,)
|
|
411
|
+
Integer or string class labels. Re-encoded to ``0..n_classes-1``
|
|
412
|
+
internally; original labels are preserved in :attr:`classes_`.
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
self : BaseSpectralClassifier
|
|
417
|
+
The fitted estimator.
|
|
418
|
+
"""
|
|
419
|
+
seed_everything(int(self.random_state))
|
|
420
|
+
X_np, y_encoded = self._prepare_inputs(X, y)
|
|
421
|
+
device = self._resolve_device()
|
|
422
|
+
|
|
423
|
+
warper = clone(self.warping) if self.warping is not None else None
|
|
424
|
+
train_loader, val_loader, stats = make_loaders(
|
|
425
|
+
X_np,
|
|
426
|
+
y_encoded,
|
|
427
|
+
batch_size=int(self.batch_size),
|
|
428
|
+
val_size=float(self.val_fraction),
|
|
429
|
+
random_state=int(self.random_state),
|
|
430
|
+
standardize=bool(self.standardize),
|
|
431
|
+
input_transform=self.input_transform,
|
|
432
|
+
warper=warper,
|
|
433
|
+
)
|
|
434
|
+
self.feature_mean_ = stats["mean"]
|
|
435
|
+
self.feature_std_ = stats["std"]
|
|
436
|
+
self.warper_ = stats["warper"]
|
|
437
|
+
self.input_transform_state_ = stats["input_transform_state"]
|
|
438
|
+
|
|
439
|
+
class_weight = self._compute_class_weight(y_encoded)
|
|
440
|
+
if class_weight is not None:
|
|
441
|
+
class_weight = class_weight.to(device)
|
|
442
|
+
criterion = self._build_criterion(class_weight)
|
|
443
|
+
|
|
444
|
+
X_val_t, y_val_t = self._collect_validation(val_loader, device)
|
|
445
|
+
|
|
446
|
+
# Retry-on-collapse: when ``retry_on_val_auroc_below`` is set,
|
|
447
|
+
# reseed and retrain up to ``max_retries`` more times if the
|
|
448
|
+
# final val AUROC is below threshold.
|
|
449
|
+
retry_threshold = self.retry_on_val_auroc_below
|
|
450
|
+
max_retries = max(0, int(self.max_retries))
|
|
451
|
+
total_attempts = 1 + (max_retries if retry_threshold is not None else 0)
|
|
452
|
+
final_val_auroc = float("nan")
|
|
453
|
+
base_seed = int(self.random_state)
|
|
454
|
+
fitted_model: nn.Module | None = None
|
|
455
|
+
for attempt in range(total_attempts):
|
|
456
|
+
if attempt > 0:
|
|
457
|
+
seed_everything(base_seed + 1_000_003 * attempt)
|
|
458
|
+
|
|
459
|
+
model = self._build_model().to(device)
|
|
460
|
+
|
|
461
|
+
opt_cls = (
|
|
462
|
+
torch.optim.AdamW if float(self.weight_decay) > 0 else torch.optim.Adam
|
|
463
|
+
)
|
|
464
|
+
param_groups = self._optimizer_param_groups(model)
|
|
465
|
+
if bool(self.use_sam):
|
|
466
|
+
from ..utils.sam import SAMOptimizer
|
|
467
|
+
|
|
468
|
+
optimizer = SAMOptimizer(
|
|
469
|
+
param_groups,
|
|
470
|
+
base_optimizer=opt_cls,
|
|
471
|
+
rho=float(self.sam_rho),
|
|
472
|
+
lr=float(self.learning_rate),
|
|
473
|
+
weight_decay=float(self.weight_decay),
|
|
474
|
+
)
|
|
475
|
+
else:
|
|
476
|
+
optimizer = opt_cls(
|
|
477
|
+
param_groups,
|
|
478
|
+
lr=float(self.learning_rate),
|
|
479
|
+
weight_decay=float(self.weight_decay),
|
|
480
|
+
)
|
|
481
|
+
warmup = max(0, int(self.warmup_epochs))
|
|
482
|
+
t_max = max(1, int(self.epochs) - warmup)
|
|
483
|
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
|
484
|
+
optimizer, T_max=t_max, eta_min=1e-6
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
metrics_recorder = self._build_metrics_recorder(
|
|
488
|
+
model, train_loader, X_val_t, y_val_t, device
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
early = EarlyStopping(patience=int(self.early_stopping_patience))
|
|
492
|
+
fitted_model = train_loop(
|
|
493
|
+
model,
|
|
494
|
+
train_loader,
|
|
495
|
+
(X_val_t, y_val_t),
|
|
496
|
+
criterion,
|
|
497
|
+
optimizer,
|
|
498
|
+
scheduler,
|
|
499
|
+
device,
|
|
500
|
+
int(self.epochs),
|
|
501
|
+
early,
|
|
502
|
+
verbose=bool(self.verbose),
|
|
503
|
+
warmup_epochs=int(self.warmup_epochs),
|
|
504
|
+
grad_clip_norm=(
|
|
505
|
+
float(self.grad_clip_norm)
|
|
506
|
+
if self.grad_clip_norm is not None
|
|
507
|
+
else None
|
|
508
|
+
),
|
|
509
|
+
use_amp=bool(self.use_amp),
|
|
510
|
+
swa_start_epoch=(
|
|
511
|
+
int(self.swa_start_epoch)
|
|
512
|
+
if self.swa_start_epoch is not None
|
|
513
|
+
else None
|
|
514
|
+
),
|
|
515
|
+
use_sam=bool(self.use_sam),
|
|
516
|
+
metrics_recorder=metrics_recorder,
|
|
517
|
+
augment=self.augment,
|
|
518
|
+
mixup_alpha=float(self.mixup_alpha),
|
|
519
|
+
cutmix_alpha=float(self.cutmix_alpha),
|
|
520
|
+
n_classes=int(self.n_classes_),
|
|
521
|
+
mix_generator=self._make_mix_generator(),
|
|
522
|
+
ema_decay=(
|
|
523
|
+
float(self.ema_decay) if self.ema_decay is not None else None
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if retry_threshold is None or attempt == total_attempts - 1:
|
|
528
|
+
break
|
|
529
|
+
final_val_auroc = self._attempt_val_auroc(
|
|
530
|
+
fitted_model, X_val_t, y_val_t, device
|
|
531
|
+
)
|
|
532
|
+
if not np.isfinite(final_val_auroc) or final_val_auroc >= float(
|
|
533
|
+
retry_threshold
|
|
534
|
+
):
|
|
535
|
+
break
|
|
536
|
+
assert fitted_model is not None
|
|
537
|
+
self.model_ = fitted_model
|
|
538
|
+
self._device_ = device
|
|
539
|
+
self.threshold_ = None
|
|
540
|
+
self.temperature_ = None
|
|
541
|
+
|
|
542
|
+
if self.metrics_log_path is not None:
|
|
543
|
+
self._write_post_fit_sidecar(X_val_t, y_val_t)
|
|
544
|
+
|
|
545
|
+
if bool(self.tune_threshold) or bool(self.calibrate_temperature):
|
|
546
|
+
self._fit_post_hoc_calibration(X_val_t, y_val_t)
|
|
547
|
+
|
|
548
|
+
return self
|
|
549
|
+
|
|
550
|
+
def _attempt_val_auroc(
|
|
551
|
+
self,
|
|
552
|
+
model: nn.Module,
|
|
553
|
+
X_val: torch.Tensor,
|
|
554
|
+
y_val: torch.Tensor,
|
|
555
|
+
device: torch.device,
|
|
556
|
+
) -> float:
|
|
557
|
+
"""Return binary val AUROC of a freshly-fitted model, or NaN.
|
|
558
|
+
|
|
559
|
+
Returns ``NaN`` for multi-class or when the val split has only
|
|
560
|
+
one class present.
|
|
561
|
+
"""
|
|
562
|
+
from sklearn.metrics import roc_auc_score
|
|
563
|
+
|
|
564
|
+
model.eval()
|
|
565
|
+
with torch.no_grad():
|
|
566
|
+
logits = model(X_val)
|
|
567
|
+
probs = torch.softmax(logits, dim=-1).detach().cpu().numpy()
|
|
568
|
+
y_np = y_val.detach().cpu().numpy()
|
|
569
|
+
if probs.shape[1] != 2 or len(np.unique(y_np)) < 2:
|
|
570
|
+
return float("nan")
|
|
571
|
+
try:
|
|
572
|
+
return float(roc_auc_score(y_np, probs[:, 1]))
|
|
573
|
+
except ValueError:
|
|
574
|
+
return float("nan")
|
|
575
|
+
|
|
576
|
+
def _write_post_fit_sidecar(self, X_val: torch.Tensor, y_val: torch.Tensor) -> None:
|
|
577
|
+
"""Compute val loss + AUROC on the deployed model and write a JSON sidecar."""
|
|
578
|
+
from sklearn.metrics import roc_auc_score
|
|
579
|
+
|
|
580
|
+
log_path = Path(self.metrics_log_path)
|
|
581
|
+
sidecar_path = log_path.with_suffix(log_path.suffix + ".post_fit.json")
|
|
582
|
+
|
|
583
|
+
self.model_.eval()
|
|
584
|
+
with torch.no_grad():
|
|
585
|
+
logits = self.model_(X_val)
|
|
586
|
+
probs = torch.softmax(logits, dim=-1).detach().cpu().numpy()
|
|
587
|
+
y_np = y_val.detach().cpu().numpy()
|
|
588
|
+
ce = nn.CrossEntropyLoss()(logits, y_val)
|
|
589
|
+
val_loss = float(ce.item())
|
|
590
|
+
|
|
591
|
+
val_auroc: float | None = None
|
|
592
|
+
n_classes = probs.shape[1]
|
|
593
|
+
try:
|
|
594
|
+
if n_classes == 2:
|
|
595
|
+
val_auroc = float(roc_auc_score(y_np, probs[:, 1]))
|
|
596
|
+
else:
|
|
597
|
+
val_auroc = float(
|
|
598
|
+
roc_auc_score(y_np, probs, multi_class="ovr", average="macro")
|
|
599
|
+
)
|
|
600
|
+
except ValueError:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
if self.ema_decay is not None:
|
|
604
|
+
source = "ema"
|
|
605
|
+
elif self.swa_start_epoch is not None:
|
|
606
|
+
source = "swa"
|
|
607
|
+
else:
|
|
608
|
+
source = "best_val"
|
|
609
|
+
|
|
610
|
+
payload = {
|
|
611
|
+
"val_loss": val_loss,
|
|
612
|
+
"val_auroc": val_auroc,
|
|
613
|
+
"weights_source": source,
|
|
614
|
+
"n_classes": int(n_classes),
|
|
615
|
+
}
|
|
616
|
+
sidecar_path.write_text(json.dumps(payload, indent=2))
|
|
617
|
+
|
|
618
|
+
def _build_metrics_recorder(
|
|
619
|
+
self,
|
|
620
|
+
model: nn.Module,
|
|
621
|
+
train_loader: DataLoader,
|
|
622
|
+
X_val_t: torch.Tensor,
|
|
623
|
+
y_val_t: torch.Tensor,
|
|
624
|
+
device: torch.device,
|
|
625
|
+
) -> Callable[[dict[str, float]], None] | None:
|
|
626
|
+
"""Return a per-epoch recorder that appends diagnostics to a CSV.
|
|
627
|
+
|
|
628
|
+
Returns ``None`` (no recording) when ``metrics_log_path`` is unset.
|
|
629
|
+
"""
|
|
630
|
+
if self.metrics_log_path is None:
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
log_path = Path(self.metrics_log_path)
|
|
634
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
if log_path.exists():
|
|
636
|
+
log_path.unlink()
|
|
637
|
+
|
|
638
|
+
track_train = bool(self.track_train_metrics)
|
|
639
|
+
binary = self.n_classes_ == 2
|
|
640
|
+
|
|
641
|
+
def _collect_train_val_auroc() -> tuple[float, float]:
|
|
642
|
+
from sklearn.metrics import roc_auc_score
|
|
643
|
+
|
|
644
|
+
if not binary:
|
|
645
|
+
import math
|
|
646
|
+
|
|
647
|
+
return math.nan, math.nan
|
|
648
|
+
y_val_np = y_val_t.detach().cpu().numpy()
|
|
649
|
+
y_tr_parts: list[np.ndarray] = []
|
|
650
|
+
proba_tr_parts: list[np.ndarray] = []
|
|
651
|
+
model.eval()
|
|
652
|
+
with torch.no_grad():
|
|
653
|
+
for xb, yb in train_loader:
|
|
654
|
+
xb = xb.to(device, non_blocking=True)
|
|
655
|
+
logits = model(xb).detach()
|
|
656
|
+
proba = torch.softmax(logits, dim=-1)[:, 1].cpu().numpy()
|
|
657
|
+
proba_tr_parts.append(proba)
|
|
658
|
+
y_tr_parts.append(np.asarray(yb).ravel())
|
|
659
|
+
val_logits = model(X_val_t).detach()
|
|
660
|
+
val_proba = torch.softmax(val_logits, dim=-1)[:, 1].cpu().numpy()
|
|
661
|
+
model.train()
|
|
662
|
+
y_tr = np.concatenate(y_tr_parts)
|
|
663
|
+
proba_tr = np.concatenate(proba_tr_parts)
|
|
664
|
+
try:
|
|
665
|
+
train_auroc = float(roc_auc_score(y_tr, proba_tr))
|
|
666
|
+
except ValueError:
|
|
667
|
+
train_auroc = float("nan")
|
|
668
|
+
try:
|
|
669
|
+
val_auroc = float(roc_auc_score(y_val_np, val_proba))
|
|
670
|
+
except ValueError:
|
|
671
|
+
val_auroc = float("nan")
|
|
672
|
+
return train_auroc, val_auroc
|
|
673
|
+
|
|
674
|
+
header_written = False
|
|
675
|
+
|
|
676
|
+
def recorder(payload: dict[str, float]) -> None:
|
|
677
|
+
nonlocal header_written
|
|
678
|
+
row: dict[str, float | int] = dict(payload)
|
|
679
|
+
if track_train:
|
|
680
|
+
train_auroc, val_auroc = _collect_train_val_auroc()
|
|
681
|
+
row["train_auroc"] = train_auroc
|
|
682
|
+
row["val_auroc"] = val_auroc
|
|
683
|
+
columns = [
|
|
684
|
+
"epoch",
|
|
685
|
+
"train_loss",
|
|
686
|
+
"val_loss",
|
|
687
|
+
"lr",
|
|
688
|
+
"mean_grad_norm",
|
|
689
|
+
"n_grad_updates",
|
|
690
|
+
]
|
|
691
|
+
if track_train:
|
|
692
|
+
columns += ["train_auroc", "val_auroc"]
|
|
693
|
+
with open(log_path, "a") as fh:
|
|
694
|
+
if not header_written:
|
|
695
|
+
fh.write(",".join(columns) + "\n")
|
|
696
|
+
header_written = True
|
|
697
|
+
fh.write(",".join(str(row.get(k, "")) for k in columns) + "\n")
|
|
698
|
+
|
|
699
|
+
return recorder
|
|
700
|
+
|
|
701
|
+
def _fit_post_hoc_calibration(
|
|
702
|
+
self, X_val_t: torch.Tensor, y_val_t: torch.Tensor
|
|
703
|
+
) -> None:
|
|
704
|
+
"""Collect held-out logits once, then fit threshold / temperature."""
|
|
705
|
+
from ..utils.calibration import fit_temperature, tune_threshold
|
|
706
|
+
|
|
707
|
+
self.model_.eval()
|
|
708
|
+
with torch.no_grad():
|
|
709
|
+
val_logits = self.model_(X_val_t).detach().cpu()
|
|
710
|
+
y_val_np = y_val_t.detach().cpu().numpy()
|
|
711
|
+
|
|
712
|
+
if bool(self.calibrate_temperature):
|
|
713
|
+
self.temperature_ = float(fit_temperature(val_logits, y_val_np))
|
|
714
|
+
|
|
715
|
+
if bool(self.tune_threshold):
|
|
716
|
+
if self.n_classes_ != 2:
|
|
717
|
+
self.threshold_ = None
|
|
718
|
+
else:
|
|
719
|
+
from sklearn.metrics import roc_auc_score
|
|
720
|
+
|
|
721
|
+
logits_np = val_logits.numpy()
|
|
722
|
+
if self.temperature_ is not None:
|
|
723
|
+
logits_np = logits_np / float(self.temperature_)
|
|
724
|
+
logits_np = logits_np - logits_np.max(axis=1, keepdims=True)
|
|
725
|
+
exp = np.exp(logits_np)
|
|
726
|
+
proba = exp / exp.sum(axis=1, keepdims=True)
|
|
727
|
+
try:
|
|
728
|
+
val_auroc = float(roc_auc_score(y_val_np, proba[:, 1]))
|
|
729
|
+
except ValueError:
|
|
730
|
+
val_auroc = float("nan")
|
|
731
|
+
gate = float(self.min_val_auroc_for_threshold_tune)
|
|
732
|
+
if np.isfinite(val_auroc) and val_auroc >= gate:
|
|
733
|
+
self.threshold_ = float(
|
|
734
|
+
tune_threshold(
|
|
735
|
+
y_val_np, proba[:, 1], metric=self.threshold_metric
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
import logging
|
|
740
|
+
|
|
741
|
+
logging.getLogger(__name__).info(
|
|
742
|
+
"tune_threshold skipped: val AUROC=%.3f < %.2f; "
|
|
743
|
+
"falling back to threshold_=0.5",
|
|
744
|
+
val_auroc,
|
|
745
|
+
gate,
|
|
746
|
+
)
|
|
747
|
+
self.threshold_ = 0.5
|
|
748
|
+
|
|
749
|
+
@staticmethod
|
|
750
|
+
def _collect_validation(
|
|
751
|
+
val_loader: DataLoader, device: torch.device
|
|
752
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
753
|
+
xs, ys = [], []
|
|
754
|
+
for xb, yb in val_loader:
|
|
755
|
+
xs.append(xb)
|
|
756
|
+
ys.append(yb)
|
|
757
|
+
X_val = torch.cat(xs, dim=0).to(device)
|
|
758
|
+
y_val = torch.cat(ys, dim=0).to(device)
|
|
759
|
+
return X_val, y_val
|
|
760
|
+
|
|
761
|
+
def _check_input_dim(self, X: np.ndarray) -> None:
|
|
762
|
+
if X.shape[1] != self.input_dim_:
|
|
763
|
+
raise ValueError(
|
|
764
|
+
f"X has {X.shape[1]} features but estimator was fitted with "
|
|
765
|
+
f"input_dim={self.input_dim_}. Retrain or re-bin your data "
|
|
766
|
+
"to match the original resolution."
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
def _forward_logits(self, X: Any) -> np.ndarray:
|
|
770
|
+
check_is_fitted(self, "model_")
|
|
771
|
+
X_np = _to_numpy(X)
|
|
772
|
+
self._check_input_dim(X_np)
|
|
773
|
+
if getattr(self, "warper_", None) is not None:
|
|
774
|
+
from .data import _warp_numpy
|
|
775
|
+
|
|
776
|
+
X_np = _warp_numpy(self.warper_, X_np)
|
|
777
|
+
state = getattr(self, "input_transform_state_", None)
|
|
778
|
+
if state is not None and state.get("mode", "none") != "none":
|
|
779
|
+
from .data import apply_input_transform
|
|
780
|
+
|
|
781
|
+
X_np = apply_input_transform(X_np, state)
|
|
782
|
+
elif self.standardize and self.feature_mean_ is not None:
|
|
783
|
+
from .data import _STD_FLOOR
|
|
784
|
+
|
|
785
|
+
safe_std = np.maximum(self.feature_std_, _STD_FLOOR).astype(np.float32)
|
|
786
|
+
X_np = (X_np - self.feature_mean_) / safe_std
|
|
787
|
+
device = self._device_
|
|
788
|
+
self.model_.eval()
|
|
789
|
+
# Batch inference so large test folds don't OOM on attention
|
|
790
|
+
# architectures.
|
|
791
|
+
X_t_full = torch.from_numpy(X_np.astype(np.float32)).to(device)
|
|
792
|
+
chunk = int(getattr(self, "batch_size", 32))
|
|
793
|
+
chunk = max(1, chunk)
|
|
794
|
+
logits_chunks: list[np.ndarray] = []
|
|
795
|
+
with torch.no_grad():
|
|
796
|
+
for start in range(0, X_t_full.shape[0], chunk):
|
|
797
|
+
X_t = X_t_full[start : start + chunk]
|
|
798
|
+
logits_chunks.append(self.model_(X_t).detach().cpu().numpy())
|
|
799
|
+
logits = (
|
|
800
|
+
np.concatenate(logits_chunks, axis=0)
|
|
801
|
+
if logits_chunks
|
|
802
|
+
else np.empty((0, self.n_classes_), dtype=np.float32)
|
|
803
|
+
)
|
|
804
|
+
if logits.ndim == 1:
|
|
805
|
+
logits = logits.reshape(-1, 1)
|
|
806
|
+
return logits
|
|
807
|
+
|
|
808
|
+
def predict_proba(self, X: Any) -> np.ndarray:
|
|
809
|
+
"""Return softmax class probabilities of shape ``(n_samples, n_classes)``.
|
|
810
|
+
|
|
811
|
+
Parameters
|
|
812
|
+
----------
|
|
813
|
+
X : array-like or MaldiSet of shape (n_samples, n_bins)
|
|
814
|
+
Spectra to score. Must have the same number of features as
|
|
815
|
+
the training matrix.
|
|
816
|
+
|
|
817
|
+
Returns
|
|
818
|
+
-------
|
|
819
|
+
ndarray of shape (n_samples, n_classes)
|
|
820
|
+
Softmax probabilities that sum to 1 along the class axis.
|
|
821
|
+
|
|
822
|
+
Raises
|
|
823
|
+
------
|
|
824
|
+
ValueError
|
|
825
|
+
If ``X.shape[1] != input_dim_``.
|
|
826
|
+
"""
|
|
827
|
+
logits = self._forward_logits(X)
|
|
828
|
+
temperature = getattr(self, "temperature_", None)
|
|
829
|
+
if temperature is not None:
|
|
830
|
+
logits = logits / float(temperature)
|
|
831
|
+
logits = logits - logits.max(axis=1, keepdims=True)
|
|
832
|
+
exp = np.exp(logits)
|
|
833
|
+
return exp / exp.sum(axis=1, keepdims=True)
|
|
834
|
+
|
|
835
|
+
def predict(self, X: Any) -> np.ndarray:
|
|
836
|
+
"""Return hard class predictions.
|
|
837
|
+
|
|
838
|
+
Parameters
|
|
839
|
+
----------
|
|
840
|
+
X : array-like or MaldiSet of shape (n_samples, n_bins)
|
|
841
|
+
Spectra to classify.
|
|
842
|
+
|
|
843
|
+
Returns
|
|
844
|
+
-------
|
|
845
|
+
ndarray of shape (n_samples,)
|
|
846
|
+
Predicted labels, drawn from :attr:`classes_`.
|
|
847
|
+
|
|
848
|
+
Notes
|
|
849
|
+
-----
|
|
850
|
+
For binary classifiers fit with ``tune_threshold=True``, the
|
|
851
|
+
decision uses the fitted :attr:`threshold_` on the positive
|
|
852
|
+
class probability instead of ``argmax``.
|
|
853
|
+
"""
|
|
854
|
+
proba = self.predict_proba(X)
|
|
855
|
+
threshold = getattr(self, "threshold_", None)
|
|
856
|
+
if threshold is not None and proba.shape[1] == 2:
|
|
857
|
+
idx = (proba[:, 1] >= float(threshold)).astype(int)
|
|
858
|
+
else:
|
|
859
|
+
idx = np.argmax(proba, axis=1)
|
|
860
|
+
return self.classes_[idx]
|
|
861
|
+
|
|
862
|
+
def score(self, X: Any, y: Any) -> float:
|
|
863
|
+
"""Return mean accuracy on ``(X, y)``.
|
|
864
|
+
|
|
865
|
+
Parameters
|
|
866
|
+
----------
|
|
867
|
+
X : array-like of shape (n_samples, n_bins)
|
|
868
|
+
y : array-like of shape (n_samples,)
|
|
869
|
+
|
|
870
|
+
Returns
|
|
871
|
+
-------
|
|
872
|
+
float
|
|
873
|
+
Accuracy in ``[0, 1]``.
|
|
874
|
+
"""
|
|
875
|
+
if hasattr(y, "to_numpy"):
|
|
876
|
+
y = y.to_numpy()
|
|
877
|
+
y = np.asarray(y).ravel()
|
|
878
|
+
preds = self.predict(X)
|
|
879
|
+
return float(np.mean(preds == y))
|
|
880
|
+
|
|
881
|
+
def _hparam_dict(self) -> dict[str, Any]:
|
|
882
|
+
params = self.get_params(deep=False)
|
|
883
|
+
if isinstance(params.get("device"), torch.device):
|
|
884
|
+
params["device"] = str(params["device"])
|
|
885
|
+
if isinstance(params.get("class_weight"), np.ndarray):
|
|
886
|
+
params["class_weight"] = params["class_weight"].tolist()
|
|
887
|
+
params["warping"] = None if params.get("warping") is None else "<provided>"
|
|
888
|
+
return params
|
|
889
|
+
|
|
890
|
+
def save(self, path: str | Path) -> None:
|
|
891
|
+
"""Persist the fitted estimator to ``path.pt`` + ``path.json``.
|
|
892
|
+
|
|
893
|
+
The PyTorch state dict is written to ``<path>.pt`` and the
|
|
894
|
+
hyperparameters plus fitted metadata to ``<path>.json``. A
|
|
895
|
+
single ``.pt`` or ``.json`` suffix on ``path`` is stripped
|
|
896
|
+
so ``clf.save("model")`` and ``clf.save("model.pt")`` produce
|
|
897
|
+
the same pair of files.
|
|
898
|
+
|
|
899
|
+
Parameters
|
|
900
|
+
----------
|
|
901
|
+
path : str or Path
|
|
902
|
+
Base path without extension.
|
|
903
|
+
"""
|
|
904
|
+
check_is_fitted(self, "model_")
|
|
905
|
+
base = Path(path)
|
|
906
|
+
if base.suffix in {".pt", ".pth", ".json"}:
|
|
907
|
+
base = base.with_suffix("")
|
|
908
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
909
|
+
|
|
910
|
+
torch.save(self.model_.state_dict(), base.with_suffix(".pt"))
|
|
911
|
+
warper = getattr(self, "warper_", None)
|
|
912
|
+
warper_path = base.with_suffix(".warper.pkl")
|
|
913
|
+
if warper is not None:
|
|
914
|
+
import joblib
|
|
915
|
+
|
|
916
|
+
joblib.dump(warper, warper_path)
|
|
917
|
+
elif warper_path.exists():
|
|
918
|
+
warper_path.unlink()
|
|
919
|
+
|
|
920
|
+
meta: dict[str, Any] = {
|
|
921
|
+
"class_name": type(self).__name__,
|
|
922
|
+
"version": 2,
|
|
923
|
+
"hparams": self._hparam_dict(),
|
|
924
|
+
"fitted": {
|
|
925
|
+
"input_dim_": int(self.input_dim_),
|
|
926
|
+
"n_classes_": int(self.n_classes_),
|
|
927
|
+
"classes_": np.asarray(self.classes_).tolist(),
|
|
928
|
+
"n_features_in_": int(self.n_features_in_),
|
|
929
|
+
"feature_mean_": (
|
|
930
|
+
None
|
|
931
|
+
if self.feature_mean_ is None
|
|
932
|
+
else np.asarray(self.feature_mean_).tolist()
|
|
933
|
+
),
|
|
934
|
+
"feature_std_": (
|
|
935
|
+
None
|
|
936
|
+
if self.feature_std_ is None
|
|
937
|
+
else np.asarray(self.feature_std_).tolist()
|
|
938
|
+
),
|
|
939
|
+
"threshold_": getattr(self, "threshold_", None),
|
|
940
|
+
"temperature_": getattr(self, "temperature_", None),
|
|
941
|
+
"has_warper": warper is not None,
|
|
942
|
+
"input_transform_state_": _serialise_transform_state(
|
|
943
|
+
getattr(self, "input_transform_state_", None)
|
|
944
|
+
),
|
|
945
|
+
},
|
|
946
|
+
}
|
|
947
|
+
with open(base.with_suffix(".json"), "w") as fh:
|
|
948
|
+
json.dump(meta, fh, indent=2)
|
|
949
|
+
|
|
950
|
+
@classmethod
|
|
951
|
+
def load(cls, path: str | Path) -> BaseSpectralClassifier:
|
|
952
|
+
"""Load a saved estimator from a ``save()``-produced pair of files.
|
|
953
|
+
|
|
954
|
+
Parameters
|
|
955
|
+
----------
|
|
956
|
+
path : str or Path
|
|
957
|
+
Base path (``.pt``/``.json`` suffix optional).
|
|
958
|
+
|
|
959
|
+
Returns
|
|
960
|
+
-------
|
|
961
|
+
BaseSpectralClassifier
|
|
962
|
+
Fitted estimator ready for :meth:`predict` /
|
|
963
|
+
:meth:`predict_proba`.
|
|
964
|
+
|
|
965
|
+
Raises
|
|
966
|
+
------
|
|
967
|
+
ValueError
|
|
968
|
+
If the JSON file identifies a different class from ``cls``.
|
|
969
|
+
FileNotFoundError
|
|
970
|
+
If either ``.pt`` or ``.json`` is missing.
|
|
971
|
+
"""
|
|
972
|
+
base = Path(path)
|
|
973
|
+
if base.suffix in {".pt", ".pth", ".json"}:
|
|
974
|
+
base = base.with_suffix("")
|
|
975
|
+
pt_path = base.with_suffix(".pt")
|
|
976
|
+
json_path = base.with_suffix(".json")
|
|
977
|
+
if not pt_path.exists():
|
|
978
|
+
raise FileNotFoundError(pt_path)
|
|
979
|
+
if not json_path.exists():
|
|
980
|
+
raise FileNotFoundError(json_path)
|
|
981
|
+
|
|
982
|
+
with open(json_path) as fh:
|
|
983
|
+
meta = json.load(fh)
|
|
984
|
+
|
|
985
|
+
if cls is not BaseSpectralClassifier and meta["class_name"] != cls.__name__:
|
|
986
|
+
raise ValueError(
|
|
987
|
+
f"Saved class is {meta['class_name']!r} but load() was called "
|
|
988
|
+
f"on {cls.__name__!r}."
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
target_cls = cls
|
|
992
|
+
if cls is BaseSpectralClassifier:
|
|
993
|
+
from .. import (
|
|
994
|
+
MaldiCNNClassifier,
|
|
995
|
+
MaldiMLPClassifier,
|
|
996
|
+
MaldiResNetClassifier,
|
|
997
|
+
MaldiTransformerClassifier,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
registry = {
|
|
1001
|
+
c.__name__: c
|
|
1002
|
+
for c in (
|
|
1003
|
+
MaldiMLPClassifier,
|
|
1004
|
+
MaldiCNNClassifier,
|
|
1005
|
+
MaldiResNetClassifier,
|
|
1006
|
+
MaldiTransformerClassifier,
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
if meta["class_name"] not in registry:
|
|
1010
|
+
raise ValueError(f"Unknown saved class: {meta['class_name']!r}")
|
|
1011
|
+
target_cls = registry[meta["class_name"]]
|
|
1012
|
+
|
|
1013
|
+
hparams = dict(meta["hparams"])
|
|
1014
|
+
hparams.pop("warping", None)
|
|
1015
|
+
instance = target_cls(**hparams)
|
|
1016
|
+
fitted = meta["fitted"]
|
|
1017
|
+
instance.input_dim_ = int(fitted["input_dim_"])
|
|
1018
|
+
instance.n_classes_ = int(fitted["n_classes_"])
|
|
1019
|
+
instance.classes_ = np.asarray(fitted["classes_"])
|
|
1020
|
+
instance.n_features_in_ = int(fitted["n_features_in_"])
|
|
1021
|
+
instance.feature_mean_ = (
|
|
1022
|
+
None
|
|
1023
|
+
if fitted["feature_mean_"] is None
|
|
1024
|
+
else np.asarray(fitted["feature_mean_"], dtype=np.float32)
|
|
1025
|
+
)
|
|
1026
|
+
instance.feature_std_ = (
|
|
1027
|
+
None
|
|
1028
|
+
if fitted["feature_std_"] is None
|
|
1029
|
+
else np.asarray(fitted["feature_std_"], dtype=np.float32)
|
|
1030
|
+
)
|
|
1031
|
+
instance.threshold_ = fitted.get("threshold_")
|
|
1032
|
+
instance.temperature_ = fitted.get("temperature_")
|
|
1033
|
+
instance.input_transform_state_ = _deserialise_transform_state(
|
|
1034
|
+
fitted.get("input_transform_state_")
|
|
1035
|
+
)
|
|
1036
|
+
instance.warper_ = None
|
|
1037
|
+
if fitted.get("has_warper"):
|
|
1038
|
+
import joblib
|
|
1039
|
+
|
|
1040
|
+
warper_path = base.with_suffix(".warper.pkl")
|
|
1041
|
+
if not warper_path.exists():
|
|
1042
|
+
raise FileNotFoundError(warper_path)
|
|
1043
|
+
instance.warper_ = joblib.load(warper_path)
|
|
1044
|
+
|
|
1045
|
+
device = resolve_device(instance.device)
|
|
1046
|
+
model = instance._build_model().to(device)
|
|
1047
|
+
state = torch.load(pt_path, map_location=device, weights_only=True)
|
|
1048
|
+
model.load_state_dict(state)
|
|
1049
|
+
model.eval()
|
|
1050
|
+
instance.model_ = model
|
|
1051
|
+
instance._device_ = device
|
|
1052
|
+
return instance
|
|
1053
|
+
|
|
1054
|
+
def __sklearn_is_fitted__(self) -> bool:
|
|
1055
|
+
return hasattr(self, "model_")
|
|
1056
|
+
|
|
1057
|
+
def __sklearn_tags__(self): # pragma: no cover - sklearn >=1.6 tag plumbing
|
|
1058
|
+
try:
|
|
1059
|
+
tags = super().__sklearn_tags__()
|
|
1060
|
+
except AttributeError:
|
|
1061
|
+
return None
|
|
1062
|
+
tags.input_tags.two_d_array = True
|
|
1063
|
+
tags.input_tags.sparse = False
|
|
1064
|
+
tags.classifier_tags.multi_class = True
|
|
1065
|
+
tags.classifier_tags.poor_score = True
|
|
1066
|
+
tags.non_deterministic = False
|
|
1067
|
+
return tags
|
|
1068
|
+
|
|
1069
|
+
def _more_tags(self) -> dict[str, Any]: # pragma: no cover - sklearn <1.6
|
|
1070
|
+
return {
|
|
1071
|
+
"binary_only": False,
|
|
1072
|
+
"multioutput": False,
|
|
1073
|
+
"poor_score": True,
|
|
1074
|
+
"requires_positive_X": False,
|
|
1075
|
+
"X_types": ["2darray"],
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
__all__ = ["BaseSpectralClassifier", "SpectralDataset"]
|