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,132 @@
1
+ """Simple mean-of-``predict_proba`` ensemble for spectral classifiers.
2
+
3
+ A thin wrapper that fits each member independently and averages
4
+ ``predict_proba`` across them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+
15
+ class SpectralEnsemble:
16
+ """Ensemble N fitted or unfitted spectral classifiers.
17
+
18
+ Parameters
19
+ ----------
20
+ classifiers : sequence of BaseSpectralClassifier
21
+ Unfitted classifier instances. :meth:`fit` calls each
22
+ member's own ``fit`` in order.
23
+
24
+ Attributes
25
+ ----------
26
+ classes_ : np.ndarray
27
+ Union of class labels reported by the members. Members must
28
+ agree on the label set after fitting.
29
+ """
30
+
31
+ def __init__(self, classifiers: list[Any]) -> None:
32
+ if not classifiers:
33
+ raise ValueError("SpectralEnsemble needs at least one classifier.")
34
+ self.classifiers = list(classifiers)
35
+
36
+ def fit(self, X: Any, y: Any) -> SpectralEnsemble:
37
+ """Fit every member on the same ``(X, y)``."""
38
+ first_classes: np.ndarray | None = None
39
+ for i, clf in enumerate(self.classifiers):
40
+ clf.fit(X, y)
41
+ if first_classes is None:
42
+ first_classes = np.asarray(clf.classes_)
43
+ elif not np.array_equal(clf.classes_, first_classes):
44
+ raise ValueError(
45
+ f"Ensemble member {i} produced classes_={clf.classes_!r}; "
46
+ f"expected {first_classes!r}. All members must see the same labels."
47
+ )
48
+ self.classes_ = first_classes
49
+ return self
50
+
51
+ def predict_proba(self, X: Any) -> np.ndarray:
52
+ """Return the mean of member ``predict_proba`` outputs."""
53
+ probas = [clf.predict_proba(X) for clf in self.classifiers]
54
+ stacked = np.stack(probas, axis=0)
55
+ return stacked.mean(axis=0)
56
+
57
+ def predict(self, X: Any) -> np.ndarray:
58
+ """Argmax of the averaged probabilities.
59
+
60
+ Per-member post-hoc calibration / thresholds are intentionally
61
+ not averaged.
62
+ """
63
+ proba = self.predict_proba(X)
64
+ idx = np.argmax(proba, axis=1)
65
+ return np.asarray(self.classes_)[idx]
66
+
67
+ def score(self, X: Any, y: Any) -> float:
68
+ """Mean accuracy against ``y``."""
69
+ preds = self.predict(X)
70
+ if hasattr(y, "to_numpy"):
71
+ y = y.to_numpy()
72
+ return float(np.mean(preds == np.asarray(y).ravel()))
73
+
74
+ def save(self, path: str | Path) -> None:
75
+ """Save each member under ``<path>_<i>``.
76
+
77
+ Example: ``SpectralEnsemble.save("my_ens")`` writes
78
+ ``my_ens_0.pt`` / ``my_ens_0.json`` / ... plus an index file
79
+ ``my_ens.ensemble.json`` recording the per-member classes.
80
+ """
81
+ import json
82
+
83
+ base = Path(path)
84
+ if base.suffix:
85
+ base = base.with_suffix("")
86
+ base.parent.mkdir(parents=True, exist_ok=True)
87
+ member_paths = []
88
+ for i, clf in enumerate(self.classifiers):
89
+ member_path = base.parent / f"{base.name}_{i}"
90
+ clf.save(member_path)
91
+ member_paths.append(str(member_path.name))
92
+ index_path = base.with_suffix(".ensemble.json")
93
+ with open(index_path, "w") as fh:
94
+ json.dump(
95
+ {
96
+ "version": 1,
97
+ "n_members": len(self.classifiers),
98
+ "member_files": member_paths,
99
+ "classes_": (
100
+ np.asarray(self.classes_).tolist()
101
+ if hasattr(self, "classes_") and self.classes_ is not None
102
+ else None
103
+ ),
104
+ "member_class_names": [type(c).__name__ for c in self.classifiers],
105
+ },
106
+ fh,
107
+ indent=2,
108
+ )
109
+
110
+ @classmethod
111
+ def load(cls, path: str | Path) -> SpectralEnsemble:
112
+ """Inverse of :meth:`save`."""
113
+ import json
114
+
115
+ from ..base.classifier import BaseSpectralClassifier
116
+
117
+ base = Path(path)
118
+ if base.suffix:
119
+ base = base.with_suffix("")
120
+ index_path = base.with_suffix(".ensemble.json")
121
+ if not index_path.exists():
122
+ raise FileNotFoundError(index_path)
123
+ with open(index_path) as fh:
124
+ meta = json.load(fh)
125
+ members: list[Any] = []
126
+ for name in meta["member_files"]:
127
+ member_path = base.parent / name
128
+ members.append(BaseSpectralClassifier.load(member_path))
129
+ ens = cls(members)
130
+ if meta.get("classes_") is not None:
131
+ ens.classes_ = np.asarray(meta["classes_"])
132
+ return ens
@@ -0,0 +1,138 @@
1
+ """Loss functions used by :class:`BaseSpectralClassifier`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import nn
8
+
9
+
10
+ class FocalLoss(nn.Module):
11
+ r"""Multi-class focal loss with optional class weighting and label smoothing.
12
+
13
+ Implements
14
+
15
+ .. math::
16
+
17
+ L = - (1 - p_t)^\gamma \log p_t
18
+
19
+ where :math:`p_t` is the predicted probability of the true class.
20
+ At :math:`\gamma = 0` and ``label_smoothing=0`` this reduces to
21
+ :class:`~torch.nn.CrossEntropyLoss`.
22
+
23
+ Parameters
24
+ ----------
25
+ weight : torch.Tensor or None, default=None
26
+ Per-class weight tensor of shape ``(n_classes,)``. Applied to
27
+ every sample by gathering at its target index (matches the
28
+ :class:`CrossEntropyLoss` convention for ``weight``).
29
+ gamma : float, default=2.0
30
+ Focusing parameter. ``0`` degrades to cross-entropy; ``2`` is
31
+ the value used in Lin et al. 2017.
32
+ label_smoothing : float, default=0.0
33
+ Target smoothing in ``[0, 1)``. At ``0.0`` the target is a
34
+ one-hot vector; otherwise the target distribution becomes
35
+ ``(1 - eps) * one_hot + eps / n_classes``.
36
+ reduction : {"mean", "sum", "none"}, default="mean"
37
+ How to reduce the per-sample loss tensor.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ weight: torch.Tensor | None = None,
43
+ gamma: float = 2.0,
44
+ label_smoothing: float = 0.0,
45
+ reduction: str = "mean",
46
+ ) -> None:
47
+ super().__init__()
48
+ if gamma < 0:
49
+ raise ValueError(f"gamma must be >= 0; got {gamma!r}.")
50
+ if not 0.0 <= label_smoothing < 1.0:
51
+ raise ValueError(
52
+ f"label_smoothing must be in [0, 1); got {label_smoothing!r}."
53
+ )
54
+ if reduction not in {"mean", "sum", "none"}:
55
+ raise ValueError(
56
+ f"reduction must be 'mean', 'sum', or 'none'; got {reduction!r}."
57
+ )
58
+ self.register_buffer(
59
+ "class_weight",
60
+ weight.detach().clone() if weight is not None else None,
61
+ persistent=False,
62
+ )
63
+ self.gamma = float(gamma)
64
+ self.label_smoothing = float(label_smoothing)
65
+ self.reduction = reduction
66
+
67
+ def forward(self, logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
68
+ r"""Compute focal loss for ``(N, C)`` logits.
69
+
70
+ Accepts either integer targets of shape ``(N,)`` or a soft
71
+ probability distribution of shape ``(N, C)`` (as produced by
72
+ MixUp / CutMix). When soft targets are passed the loss
73
+ becomes
74
+
75
+ .. math::
76
+
77
+ L = - \sum_c t_c \, (1 - p_c)^\gamma \log p_c
78
+
79
+ ``label_smoothing`` is ignored on the soft-target path.
80
+
81
+ Class weighting follows the :class:`~torch.nn.CrossEntropyLoss`
82
+ convention with ``reduction="mean"``: the per-sample weight is
83
+ ``weight[y_i]`` (or ``Σ_c t_c · weight_c`` for soft targets),
84
+ and the mean reduction divides by ``Σ_i sample_weight_i``
85
+ rather than ``N``.
86
+ """
87
+ log_probs = F.log_softmax(logits, dim=-1)
88
+ probs = log_probs.exp()
89
+ n_classes = logits.shape[-1]
90
+ sample_weight: torch.Tensor | None = None
91
+
92
+ if target.dim() == 2:
93
+ smooth = target.to(dtype=log_probs.dtype)
94
+ focal_per_class = (1.0 - probs).clamp_min(1e-12).pow(self.gamma)
95
+ per_class_loss = -smooth * focal_per_class * log_probs
96
+ loss = per_class_loss.sum(dim=-1)
97
+ if self.class_weight is not None:
98
+ w = self.class_weight.to(loss.device)
99
+ sample_weight = (smooth * w).sum(dim=-1)
100
+ loss = loss * sample_weight
101
+ elif self.label_smoothing == 0.0:
102
+ logpt = log_probs.gather(1, target.unsqueeze(1)).squeeze(1)
103
+ pt = probs.gather(1, target.unsqueeze(1)).squeeze(1)
104
+ focal_term = (1.0 - pt).clamp_min(1e-12).pow(self.gamma)
105
+ loss = -focal_term * logpt
106
+ if self.class_weight is not None:
107
+ sample_weight = self.class_weight.to(loss.device).gather(0, target)
108
+ loss = loss * sample_weight
109
+ else:
110
+ eps = self.label_smoothing
111
+ smooth = torch.full_like(probs, eps / n_classes)
112
+ smooth.scatter_(
113
+ 1,
114
+ target.unsqueeze(1),
115
+ smooth.gather(1, target.unsqueeze(1)) + (1.0 - eps),
116
+ )
117
+ focal_per_class = (1.0 - probs).clamp_min(1e-12).pow(self.gamma)
118
+ per_class_loss = -smooth * focal_per_class * log_probs
119
+ loss = per_class_loss.sum(dim=-1)
120
+ if self.class_weight is not None:
121
+ sample_weight = self.class_weight.to(loss.device).gather(0, target)
122
+ loss = loss * sample_weight
123
+
124
+ if self.reduction == "mean":
125
+ if sample_weight is not None:
126
+ denom = sample_weight.sum().clamp_min(1e-12)
127
+ return loss.sum() / denom
128
+ return loss.mean()
129
+ if self.reduction == "sum":
130
+ return loss.sum()
131
+ return loss
132
+
133
+ def extra_repr(self) -> str:
134
+ """Return a string with the focal-loss hyperparameters for ``repr``."""
135
+ return (
136
+ f"gamma={self.gamma}, label_smoothing={self.label_smoothing}, "
137
+ f"reduction={self.reduction!r}"
138
+ )
@@ -0,0 +1,173 @@
1
+ """Learning-rate finder.
2
+
3
+ Sweeps the learning rate geometrically over a small training run and
4
+ records the smoothed loss at each step. The minimum of the smoothed
5
+ loss curve's gradient gives a reasonable starting point for the base
6
+ learning rate.
7
+
8
+ Diagnostic only; not wired into
9
+ :meth:`~maldideepkit.BaseSpectralClassifier.fit`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import numpy as np
17
+ import torch
18
+ from torch import nn
19
+
20
+ from .loss import FocalLoss
21
+ from .reproducibility import resolve_device, seed_everything
22
+
23
+ if TYPE_CHECKING:
24
+ from ..base.classifier import BaseSpectralClassifier
25
+
26
+
27
+ def find_lr(
28
+ classifier: "BaseSpectralClassifier",
29
+ X: Any,
30
+ y: Any,
31
+ *,
32
+ start_lr: float = 1e-8,
33
+ end_lr: float = 1.0,
34
+ num_iter: int = 200,
35
+ smoothing: float = 0.98,
36
+ divergence_factor: float = 4.0,
37
+ plot: bool = False,
38
+ ) -> dict[str, Any]:
39
+ """Sweep learning rate geometrically and return the LR / loss curve.
40
+
41
+ Parameters
42
+ ----------
43
+ classifier : BaseSpectralClassifier
44
+ An unfitted classifier configured with the desired architecture
45
+ / batch_size / loss. Its ``learning_rate`` is ignored (we drive
46
+ it manually across the sweep) and its weights are reset at the
47
+ start of every call.
48
+ X, y : array-like
49
+ Training data. Only enough batches to cover ``num_iter`` steps
50
+ are consumed.
51
+ start_lr, end_lr : float, default=1e-8, 1.0
52
+ Bounds of the geometric LR sweep.
53
+ num_iter : int, default=200
54
+ Number of steps in the sweep.
55
+ smoothing : float, default=0.98
56
+ Exponential-moving-average factor applied to the per-step loss
57
+ (0 = no smoothing, 0.99 = heavy smoothing).
58
+ divergence_factor : float, default=4.0
59
+ Stop early once the smoothed loss exceeds
60
+ ``divergence_factor * min_smoothed_loss``.
61
+ plot : bool, default=False
62
+ If ``True``, render a matplotlib plot. ``matplotlib`` is only
63
+ imported when this is true.
64
+
65
+ Returns
66
+ -------
67
+ dict
68
+ ``{"lrs": np.ndarray, "losses": np.ndarray, "suggested_lr": float}``.
69
+ ``suggested_lr`` is the LR at the steepest-descent point of the
70
+ smoothed loss curve.
71
+ """
72
+ from ..base.data import make_loaders
73
+
74
+ seed_everything(int(classifier.random_state))
75
+ device = resolve_device(classifier.device)
76
+
77
+ X_np, y_encoded = classifier._prepare_inputs(X, y)
78
+ train_loader, _, stats = make_loaders(
79
+ X_np,
80
+ y_encoded,
81
+ batch_size=int(classifier.batch_size),
82
+ val_size=float(classifier.val_fraction),
83
+ random_state=int(classifier.random_state),
84
+ standardize=bool(classifier.standardize),
85
+ )
86
+ classifier.feature_mean_ = stats["mean"]
87
+ classifier.feature_std_ = stats["std"]
88
+ model = classifier._build_model().to(device)
89
+
90
+ class_weight = classifier._compute_class_weight(y_encoded)
91
+ if class_weight is not None:
92
+ class_weight = class_weight.to(device)
93
+ if classifier.loss == "cross_entropy":
94
+ criterion: nn.Module = nn.CrossEntropyLoss(
95
+ weight=class_weight, label_smoothing=float(classifier.label_smoothing)
96
+ )
97
+ else:
98
+ criterion = FocalLoss(
99
+ weight=class_weight,
100
+ gamma=float(classifier.focal_gamma),
101
+ label_smoothing=float(classifier.label_smoothing),
102
+ )
103
+ optimizer = torch.optim.Adam(model.parameters(), lr=start_lr)
104
+
105
+ lrs: list[float] = []
106
+ losses: list[float] = []
107
+ best_smoothed = float("inf")
108
+ smoothed = 0.0
109
+ log_step = (np.log(end_lr) - np.log(start_lr)) / max(1, num_iter - 1)
110
+
111
+ model.train()
112
+ step = 0
113
+ data_iter = iter(train_loader)
114
+ while step < num_iter:
115
+ try:
116
+ xb, yb = next(data_iter)
117
+ except StopIteration:
118
+ data_iter = iter(train_loader)
119
+ xb, yb = next(data_iter)
120
+ xb = xb.to(device, non_blocking=True)
121
+ yb = yb.to(device, non_blocking=True)
122
+
123
+ lr = float(np.exp(np.log(start_lr) + log_step * step))
124
+ for pg in optimizer.param_groups:
125
+ pg["lr"] = lr
126
+
127
+ optimizer.zero_grad()
128
+ logits = model(xb)
129
+ loss = criterion(logits, yb)
130
+ loss.backward()
131
+ optimizer.step()
132
+
133
+ raw = float(loss.detach().item())
134
+ smoothed = smoothing * smoothed + (1 - smoothing) * raw
135
+ debiased = smoothed / (1 - smoothing ** (step + 1)) if smoothing > 0 else raw
136
+
137
+ lrs.append(lr)
138
+ losses.append(debiased)
139
+
140
+ best_smoothed = min(best_smoothed, debiased)
141
+ if debiased > divergence_factor * best_smoothed and step > 5:
142
+ break
143
+
144
+ step += 1
145
+
146
+ lrs_arr = np.asarray(lrs)
147
+ losses_arr = np.asarray(losses)
148
+ if len(lrs_arr) < 3:
149
+ suggested_idx = int(np.argmin(losses_arr))
150
+ else:
151
+ grads = np.gradient(losses_arr, np.log(lrs_arr))
152
+ suggested_idx = int(np.argmin(grads))
153
+ suggested_lr = float(lrs_arr[suggested_idx])
154
+
155
+ if plot:
156
+ import matplotlib.pyplot as plt
157
+
158
+ fig, ax = plt.subplots(figsize=(6, 4))
159
+ ax.plot(lrs_arr, losses_arr)
160
+ ax.set_xscale("log")
161
+ ax.set_xlabel("learning rate")
162
+ ax.set_ylabel("smoothed loss")
163
+ ax.axvline(
164
+ suggested_lr,
165
+ color="r",
166
+ linestyle="--",
167
+ label=f"suggested = {suggested_lr:.2e}",
168
+ )
169
+ ax.legend()
170
+ fig.tight_layout()
171
+ plt.show()
172
+
173
+ return {"lrs": lrs_arr, "losses": losses_arr, "suggested_lr": suggested_lr}
@@ -0,0 +1,70 @@
1
+ """Seeding and device-placement helpers for deterministic training."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+
8
+ import numpy as np
9
+ import torch
10
+
11
+
12
+ def seed_everything(seed: int, deterministic: bool = False) -> None:
13
+ """Seed Python, NumPy, and PyTorch (CPU + CUDA) RNGs in one call.
14
+
15
+ Parameters
16
+ ----------
17
+ seed : int
18
+ Non-negative integer used for every RNG. Also fixes
19
+ ``PYTHONHASHSEED`` in the current process environment.
20
+ deterministic : bool, default=False
21
+ When ``True``, additionally enable PyTorch's deterministic
22
+ algorithm mode. Sets
23
+ ``torch.use_deterministic_algorithms(True, warn_only=True)``,
24
+ ``torch.backends.cudnn.deterministic = True``,
25
+ ``torch.backends.cudnn.benchmark = False``, and
26
+ ``CUBLAS_WORKSPACE_CONFIG=:4096:8``. The env-var must be set
27
+ before the first CUDA context is created. Once enabled,
28
+ determinism is **sticky** - subsequent plain
29
+ ``seed_everything(seed)`` calls do not turn it off.
30
+ """
31
+ os.environ["PYTHONHASHSEED"] = str(seed)
32
+ random.seed(seed)
33
+ np.random.seed(seed)
34
+ torch.manual_seed(seed)
35
+ if torch.cuda.is_available():
36
+ torch.cuda.manual_seed_all(seed)
37
+ if deterministic:
38
+ os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
39
+ torch.use_deterministic_algorithms(True, warn_only=True)
40
+ if torch.cuda.is_available():
41
+ torch.backends.cudnn.deterministic = True
42
+ torch.backends.cudnn.benchmark = False
43
+
44
+
45
+ def resolve_device(device: str | torch.device | None) -> torch.device:
46
+ """Resolve a user-facing device specifier to a :class:`torch.device`.
47
+
48
+ Parameters
49
+ ----------
50
+ device : {"auto", "cpu", "cuda"} or torch.device or None
51
+ ``"auto"`` (or ``None``) picks ``cuda`` when available and falls
52
+ back to ``cpu``.
53
+
54
+ Returns
55
+ -------
56
+ torch.device
57
+ The resolved device.
58
+
59
+ Raises
60
+ ------
61
+ ValueError
62
+ If ``device`` is an unknown string.
63
+ """
64
+ if device is None or device == "auto":
65
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
66
+ if isinstance(device, torch.device):
67
+ return device
68
+ if isinstance(device, str):
69
+ return torch.device(device)
70
+ raise ValueError(f"Unsupported device specifier: {device!r}")
@@ -0,0 +1,121 @@
1
+ """Sharpness-Aware Minimization (SAM) optimizer wrapper.
2
+
3
+ SAM pushes weights toward flatter regions of the loss landscape, at
4
+ the cost of ~2x the forward / backward compute per step.
5
+
6
+ Usage
7
+ -----
8
+
9
+ .. code-block:: python
10
+
11
+ optimizer = SAMOptimizer(
12
+ model.parameters(), base_optimizer=torch.optim.AdamW,
13
+ rho=0.05, lr=1e-3, weight_decay=0.05,
14
+ )
15
+
16
+ loss = criterion(model(x), y)
17
+ loss.backward()
18
+ optimizer.first_step(zero_grad=True)
19
+
20
+ loss = criterion(model(x), y)
21
+ loss.backward()
22
+ optimizer.second_step(zero_grad=True)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ import torch
30
+
31
+
32
+ class SAMOptimizer(torch.optim.Optimizer):
33
+ """Wrap a base optimizer in the SAM two-step update.
34
+
35
+ Parameters
36
+ ----------
37
+ params : iterable
38
+ Parameters or param-group dicts (as for any torch optimizer).
39
+ base_optimizer : type
40
+ The base optimizer **class** (e.g. :class:`torch.optim.AdamW`).
41
+ Instantiated internally against the same param groups.
42
+ rho : float, default=0.05
43
+ Size of the ascent step in parameter space. Paper default is
44
+ ``0.05``. Typical range: ``[0.01, 0.2]``.
45
+ **base_kwargs
46
+ Forwarded to the base optimizer (e.g. ``lr``, ``weight_decay``).
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ params: Any,
52
+ base_optimizer: type[torch.optim.Optimizer],
53
+ rho: float = 0.05,
54
+ **base_kwargs: Any,
55
+ ) -> None:
56
+ if rho <= 0:
57
+ raise ValueError(f"rho must be > 0; got {rho!r}.")
58
+ defaults = {"rho": float(rho), **base_kwargs}
59
+ super().__init__(params, defaults)
60
+ self.base_optimizer = base_optimizer(self.param_groups, **base_kwargs)
61
+ self.param_groups = self.base_optimizer.param_groups
62
+ for g in self.param_groups:
63
+ g.setdefault("rho", float(rho))
64
+
65
+ @torch.no_grad()
66
+ def first_step(self, zero_grad: bool = False) -> None:
67
+ """Ascend to ``w + e`` using the current gradients."""
68
+ grad_norm = self._grad_norm()
69
+ eps = 1e-12
70
+ for group in self.param_groups:
71
+ scale = group["rho"] / (grad_norm + eps)
72
+ for p in group["params"]:
73
+ if p.grad is None:
74
+ continue
75
+ e_w = p.grad * scale
76
+ self.state[p]["e_w"] = e_w
77
+ p.add_(e_w)
78
+ if zero_grad:
79
+ self.zero_grad()
80
+
81
+ @torch.no_grad()
82
+ def second_step(self, zero_grad: bool = False) -> None:
83
+ """Undo the ascent and apply the base optimizer step from ``w``."""
84
+ for group in self.param_groups:
85
+ for p in group["params"]:
86
+ if "e_w" in self.state.get(p, {}):
87
+ p.sub_(self.state[p]["e_w"])
88
+ del self.state[p]["e_w"]
89
+ self.base_optimizer.step()
90
+ self._step_count = getattr(self.base_optimizer, "_step_count", 0)
91
+ self._opt_called = True
92
+ if zero_grad:
93
+ self.zero_grad()
94
+
95
+ def step(self, closure: Any = None) -> Any:
96
+ """Unsupported. Use ``first_step`` / ``second_step`` instead."""
97
+ raise RuntimeError(
98
+ "SAMOptimizer requires an explicit two-pass training loop. "
99
+ "Call first_step() after the first backward, then recompute "
100
+ "the loss and backward, then call second_step()."
101
+ )
102
+
103
+ @torch.no_grad()
104
+ def _grad_norm(self) -> torch.Tensor:
105
+ ref_device = None
106
+ for group in self.param_groups:
107
+ for p in group["params"]:
108
+ if p.grad is not None:
109
+ ref_device = p.grad.device
110
+ break
111
+ if ref_device is not None:
112
+ break
113
+ if ref_device is None:
114
+ return torch.tensor(0.0)
115
+ norms = [
116
+ p.grad.norm(p=2).to(ref_device)
117
+ for group in self.param_groups
118
+ for p in group["params"]
119
+ if p.grad is not None
120
+ ]
121
+ return torch.norm(torch.stack(norms), p=2)