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,386 @@
1
+ """Generic training primitives used by :class:`BaseSpectralClassifier`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+
8
+ import torch
9
+ from torch import nn
10
+ from torch.utils.data import DataLoader
11
+
12
+ from ..augment.mixing import apply_cutmix, apply_mixup, to_one_hot
13
+ from .sam import SAMOptimizer
14
+
15
+
16
+ class EarlyStopping:
17
+ """Track the best validation loss and signal when to stop training.
18
+
19
+ Parameters
20
+ ----------
21
+ patience : int
22
+ Number of consecutive epochs without improvement before
23
+ :attr:`should_stop` flips to ``True``.
24
+ min_delta : float, default=1e-6
25
+ Absolute floor on the improvement counted as progress.
26
+ min_delta_rel : float, default=0.0
27
+ Relative floor: an epoch counts as improvement only if
28
+ ``val_loss < best_loss - max(min_delta, min_delta_rel * |best_loss|)``.
29
+ Useful for losses that asymptote near small values where the
30
+ absolute ``min_delta`` is essentially never the binding
31
+ constraint.
32
+
33
+ Attributes
34
+ ----------
35
+ best_loss : float
36
+ Best validation loss observed so far (``inf`` before the first
37
+ update).
38
+ best_state : dict or None
39
+ CPU copy of the model ``state_dict`` at the best epoch.
40
+ should_stop : bool
41
+ ``True`` once ``patience`` epochs have elapsed without an
42
+ improvement.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ patience: int,
48
+ min_delta: float = 1e-6,
49
+ min_delta_rel: float = 0.0,
50
+ ) -> None:
51
+ self.patience = patience
52
+ self.min_delta = min_delta
53
+ self.min_delta_rel = min_delta_rel
54
+ self.best_loss = float("inf")
55
+ self.best_state: dict[str, torch.Tensor] | None = None
56
+ self.should_stop = False
57
+ self._stale = 0
58
+
59
+ def step(self, val_loss: float, model: nn.Module) -> bool:
60
+ """Record ``val_loss`` and snapshot ``model`` if it improved.
61
+
62
+ Parameters
63
+ ----------
64
+ val_loss : float
65
+ Validation loss for the current epoch.
66
+ model : nn.Module
67
+ Model whose parameters will be cached on improvement.
68
+
69
+ Returns
70
+ -------
71
+ bool
72
+ ``True`` if the loss improved this call.
73
+ """
74
+ if math.isfinite(self.best_loss):
75
+ threshold = max(self.min_delta, self.min_delta_rel * abs(self.best_loss))
76
+ else:
77
+ threshold = self.min_delta
78
+ if val_loss < self.best_loss - threshold:
79
+ self.best_loss = val_loss
80
+ self.best_state = {
81
+ k: v.detach().cpu().clone() for k, v in model.state_dict().items()
82
+ }
83
+ self._stale = 0
84
+ return True
85
+ self._stale += 1
86
+ if self._stale >= self.patience:
87
+ self.should_stop = True
88
+ return False
89
+
90
+
91
+ def train_loop(
92
+ model: nn.Module,
93
+ train_loader: DataLoader,
94
+ val_tensors: tuple[torch.Tensor, torch.Tensor],
95
+ criterion: nn.Module,
96
+ optimizer: torch.optim.Optimizer,
97
+ scheduler: torch.optim.lr_scheduler.LRScheduler
98
+ | torch.optim.lr_scheduler.ReduceLROnPlateau
99
+ | None,
100
+ device: torch.device,
101
+ epochs: int,
102
+ early_stopping: EarlyStopping,
103
+ verbose: bool = False,
104
+ on_epoch_end: Callable[[int, float], None] | None = None,
105
+ warmup_epochs: int = 0,
106
+ grad_clip_norm: float | None = None,
107
+ use_amp: bool = False,
108
+ swa_start_epoch: int | None = None,
109
+ use_sam: bool = False,
110
+ metrics_recorder: Callable[[dict[str, float]], None] | None = None,
111
+ augment: Callable[[torch.Tensor], torch.Tensor] | None = None,
112
+ mixup_alpha: float = 0.0,
113
+ cutmix_alpha: float = 0.0,
114
+ n_classes: int | None = None,
115
+ mix_generator: torch.Generator | None = None,
116
+ ema_decay: float | None = None,
117
+ ) -> nn.Module:
118
+ """Run a classic train + validate loop with early stopping.
119
+
120
+ Parameters
121
+ ----------
122
+ model : nn.Module
123
+ Already placed on ``device``.
124
+ train_loader : DataLoader
125
+ Iterates over ``(x, y)`` batches of training data.
126
+ val_tensors : tuple of torch.Tensor
127
+ ``(X_val, y_val)`` tensors already on ``device``.
128
+ criterion : nn.Module
129
+ Loss function, e.g. ``nn.CrossEntropyLoss``.
130
+ optimizer : torch.optim.Optimizer
131
+ Optimizer bound to ``model`` parameters.
132
+ scheduler : torch.optim.lr_scheduler.LRScheduler or ReduceLROnPlateau or None
133
+ Optional LR scheduler. :class:`ReduceLROnPlateau` is stepped on
134
+ validation loss; any other scheduler is stepped once per epoch
135
+ with no argument.
136
+ device : torch.device
137
+ Device on which training is carried out.
138
+ epochs : int
139
+ Maximum number of epochs.
140
+ early_stopping : EarlyStopping
141
+ Tracks the best validation loss and stops training when stale.
142
+ verbose : bool, default=False
143
+ If ``True``, prints one line per epoch.
144
+ on_epoch_end : callable, optional
145
+ Called as ``on_epoch_end(epoch, val_loss)`` after each epoch.
146
+ warmup_epochs : int, default=0
147
+ If positive, linearly ramp each optimizer param group's learning
148
+ rate from ``0`` to its configured target over the first
149
+ ``warmup_epochs`` epochs. ``scheduler`` is not stepped during
150
+ warmup.
151
+ grad_clip_norm : float or None, default=None
152
+ If set, clip gradient global L2 norm to this value via
153
+ :func:`torch.nn.utils.clip_grad_norm_`.
154
+ use_amp : bool, default=False
155
+ If ``True`` and ``device.type == "cuda"``, run forward + loss
156
+ under :func:`torch.autocast` and use
157
+ :class:`torch.amp.GradScaler` for backward. On CPU this is a
158
+ no-op.
159
+ swa_start_epoch : int or None, default=None
160
+ If set, maintain a :class:`torch.optim.swa_utils.AveragedModel`
161
+ starting at this epoch (0-indexed). At end of training,
162
+ replaces the best-val checkpoint with the SWA average.
163
+ use_sam : bool, default=False
164
+ If ``True``, assume ``optimizer`` is a
165
+ :class:`~maldideepkit.utils.SAMOptimizer` and run the two-step
166
+ SAM update (roughly doubles compute). Grad clipping is applied
167
+ only on the second gradient.
168
+ metrics_recorder : callable, optional
169
+ If provided, called once per epoch with a dict containing
170
+ ``{"epoch", "train_loss", "val_loss", "lr",
171
+ "mean_grad_norm", "n_grad_updates"}``.
172
+ augment : callable, optional
173
+ If provided, called on each training batch's feature tensor
174
+ after it is moved to ``device`` but before the forward pass.
175
+ mixup_alpha : float, default=0.0
176
+ When ``> 0``, apply MixUp on each training batch with a mix
177
+ coefficient drawn from ``Beta(alpha, alpha)``. Requires
178
+ ``n_classes``. Labels become soft probability distributions.
179
+ cutmix_alpha : float, default=0.0
180
+ When ``> 0``, apply CutMix on each training batch. When both
181
+ ``mixup_alpha`` and ``cutmix_alpha`` are positive a fair coin
182
+ picks between the two per batch.
183
+ n_classes : int, optional
184
+ Required when ``mixup_alpha > 0`` or ``cutmix_alpha > 0``.
185
+ mix_generator : torch.Generator, optional
186
+ Optional seeded RNG for MixUp / CutMix draws.
187
+ ema_decay : float or None, default=None
188
+ When set, maintain an exponential moving average of the
189
+ model parameters: ``ema = decay * ema + (1 - decay) * model``.
190
+ Typical values ``0.99``-``0.9999``. At end of training the
191
+ EMA weights overwrite the base model.
192
+
193
+ Returns
194
+ -------
195
+ nn.Module
196
+ The input ``model`` with the best-validation weights loaded
197
+ (or the EMA / SWA average when those are enabled - precedence
198
+ EMA > SWA > best_val).
199
+ """
200
+ mix_enabled = mixup_alpha > 0.0 or cutmix_alpha > 0.0
201
+ if mix_enabled and n_classes is None:
202
+ raise ValueError(
203
+ "mixup_alpha / cutmix_alpha require n_classes to be specified."
204
+ )
205
+ if bool(use_sam) and bool(use_amp) and device.type == "cuda":
206
+ import warnings as _warnings
207
+
208
+ _warnings.warn(
209
+ "use_sam=True with use_amp=True: SAM's two-pass update is not "
210
+ "compatible with AMP's GradScaler, so this run executes SAM "
211
+ "in FP32 (no AMP speedup). Disable one to silence.",
212
+ stacklevel=2,
213
+ )
214
+ X_val, y_val = val_tensors
215
+ base_lrs = [pg["lr"] for pg in optimizer.param_groups]
216
+ amp_enabled = bool(use_amp) and device.type == "cuda"
217
+ scaler = torch.amp.GradScaler("cuda") if amp_enabled else None
218
+ swa_model: torch.optim.swa_utils.AveragedModel | None = None
219
+ swa_updated = False
220
+ ema_model: torch.optim.swa_utils.AveragedModel | None = None
221
+ ema_updated = False
222
+ if ema_decay is not None:
223
+ if not 0.0 < float(ema_decay) < 1.0:
224
+ raise ValueError(f"ema_decay must be in (0, 1); got {ema_decay!r}.")
225
+ decay = float(ema_decay)
226
+
227
+ def _ema_avg_fn(avg: torch.Tensor, cur: torch.Tensor, _n: int) -> torch.Tensor:
228
+ return decay * avg + (1.0 - decay) * cur
229
+
230
+ ema_model = torch.optim.swa_utils.AveragedModel(model, avg_fn=_ema_avg_fn)
231
+
232
+ def _compute_grad_norm(params: list[torch.Tensor]) -> float:
233
+ grads = [p.grad for p in params if p.grad is not None]
234
+ if not grads:
235
+ return 0.0
236
+ norms = torch._foreach_norm(grads, 2.0)
237
+ return float(torch.linalg.vector_norm(torch.stack(norms)).item())
238
+
239
+ for epoch in range(epochs):
240
+ if warmup_epochs > 0 and epoch < warmup_epochs:
241
+ scale = (epoch + 1) / warmup_epochs
242
+ for pg, base in zip(optimizer.param_groups, base_lrs, strict=True):
243
+ pg["lr"] = base * scale
244
+
245
+ epoch_loss_sum = 0.0
246
+ epoch_grad_norm_sum = 0.0
247
+ epoch_n_updates = 0
248
+
249
+ model.train()
250
+ for xb, yb in train_loader:
251
+ xb = xb.to(device, non_blocking=True)
252
+ yb = yb.to(device, non_blocking=True)
253
+ if augment is not None:
254
+ xb = augment(xb)
255
+
256
+ if mix_enabled:
257
+ yb_oh = to_one_hot(yb, int(n_classes))
258
+ use_cutmix = cutmix_alpha > 0.0 and (
259
+ mixup_alpha == 0.0
260
+ or torch.rand(1, generator=mix_generator).item() < 0.5
261
+ )
262
+ if use_cutmix:
263
+ xb, yb_target = apply_cutmix(
264
+ xb, yb_oh, cutmix_alpha, generator=mix_generator
265
+ )
266
+ else:
267
+ xb, yb_target = apply_mixup(
268
+ xb, yb_oh, mixup_alpha, generator=mix_generator
269
+ )
270
+ else:
271
+ yb_target = yb
272
+
273
+ if use_sam:
274
+ if not isinstance(optimizer, SAMOptimizer):
275
+ raise TypeError(
276
+ "use_sam=True requires `optimizer` to be a SAMOptimizer; "
277
+ f"got {type(optimizer).__name__}."
278
+ )
279
+ optimizer.zero_grad()
280
+ logits = model(xb)
281
+ loss = criterion(logits, yb_target)
282
+ loss.backward()
283
+ optimizer.first_step(zero_grad=True)
284
+
285
+ logits = model(xb)
286
+ loss = criterion(logits, yb_target)
287
+ loss.backward()
288
+ if metrics_recorder is not None or grad_clip_norm is not None:
289
+ params = [p for g in optimizer.param_groups for p in g["params"]]
290
+ if metrics_recorder is not None:
291
+ epoch_grad_norm_sum += _compute_grad_norm(params)
292
+ if grad_clip_norm is not None:
293
+ torch.nn.utils.clip_grad_norm_(params, max_norm=grad_clip_norm)
294
+ optimizer.second_step(zero_grad=True)
295
+ elif amp_enabled:
296
+ assert scaler is not None
297
+ optimizer.zero_grad()
298
+ with torch.autocast(device_type="cuda"):
299
+ logits = model(xb)
300
+ loss = criterion(logits, yb_target)
301
+ scaler.scale(loss).backward()
302
+ if grad_clip_norm is not None or metrics_recorder is not None:
303
+ scaler.unscale_(optimizer)
304
+ params = [p for g in optimizer.param_groups for p in g["params"]]
305
+ if metrics_recorder is not None:
306
+ epoch_grad_norm_sum += _compute_grad_norm(params)
307
+ if grad_clip_norm is not None:
308
+ torch.nn.utils.clip_grad_norm_(params, max_norm=grad_clip_norm)
309
+ scaler.step(optimizer)
310
+ scaler.update()
311
+ else:
312
+ optimizer.zero_grad()
313
+ logits = model(xb)
314
+ loss = criterion(logits, yb_target)
315
+ loss.backward()
316
+ if metrics_recorder is not None or grad_clip_norm is not None:
317
+ params = [p for g in optimizer.param_groups for p in g["params"]]
318
+ if metrics_recorder is not None:
319
+ epoch_grad_norm_sum += _compute_grad_norm(params)
320
+ if grad_clip_norm is not None:
321
+ torch.nn.utils.clip_grad_norm_(params, max_norm=grad_clip_norm)
322
+ optimizer.step()
323
+
324
+ if ema_model is not None:
325
+ ema_model.update_parameters(model)
326
+ ema_updated = True
327
+
328
+ if metrics_recorder is not None:
329
+ epoch_loss_sum += float(loss.detach().item())
330
+ epoch_n_updates += 1
331
+
332
+ model.eval()
333
+ with torch.no_grad():
334
+ if amp_enabled:
335
+ with torch.autocast(device_type="cuda"):
336
+ val_logits = model(X_val)
337
+ val_loss = float(criterion(val_logits, y_val).item())
338
+ else:
339
+ val_logits = model(X_val)
340
+ val_loss = float(criterion(val_logits, y_val).item())
341
+
342
+ if scheduler is not None and epoch >= warmup_epochs:
343
+ if isinstance(scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
344
+ scheduler.step(val_loss)
345
+ else:
346
+ scheduler.step()
347
+
348
+ if swa_start_epoch is not None and epoch >= swa_start_epoch:
349
+ if swa_model is None:
350
+ swa_model = torch.optim.swa_utils.AveragedModel(model)
351
+ swa_model.update_parameters(model)
352
+ swa_updated = True
353
+
354
+ improved = early_stopping.step(val_loss, model)
355
+ if verbose:
356
+ marker = " *" if improved else ""
357
+ print(f"epoch {epoch + 1}/{epochs} val_loss={val_loss:.4f}{marker}")
358
+ if on_epoch_end is not None:
359
+ on_epoch_end(epoch, val_loss)
360
+ if metrics_recorder is not None:
361
+ n = max(1, epoch_n_updates)
362
+ metrics_recorder(
363
+ {
364
+ "epoch": int(epoch),
365
+ "train_loss": epoch_loss_sum / n,
366
+ "val_loss": float(val_loss),
367
+ "lr": float(optimizer.param_groups[0]["lr"]),
368
+ "mean_grad_norm": epoch_grad_norm_sum / n,
369
+ "n_grad_updates": int(epoch_n_updates),
370
+ }
371
+ )
372
+ if early_stopping.should_stop:
373
+ break
374
+
375
+ if ema_updated and ema_model is not None:
376
+ if any(isinstance(m, nn.modules.batchnorm._BatchNorm) for m in model.modules()):
377
+ torch.optim.swa_utils.update_bn(train_loader, ema_model, device=device)
378
+ model.load_state_dict(ema_model.module.state_dict())
379
+ elif swa_updated and swa_model is not None:
380
+ if any(isinstance(m, nn.modules.batchnorm._BatchNorm) for m in model.modules()):
381
+ torch.optim.swa_utils.update_bn(train_loader, swa_model, device=device)
382
+ model.load_state_dict(swa_model.module.state_dict())
383
+ elif early_stopping.best_state is not None:
384
+ model.load_state_dict(early_stopping.best_state)
385
+ model.eval()
386
+ return model