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