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,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)
|