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,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"]