mcode-neural 0.5.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.
mcode_neural/train.py ADDED
@@ -0,0 +1,756 @@
1
+ # region F I L E
2
+ # <copyright file="mcode-neural/src/mcode_neural/train.py" company="MicroCODE Incorporated">Copyright © 2026 MicroCODE, Inc. Troy, MI</copyright><author>Timothy J. McGuire</author>
3
+ # region M O D U L E
4
+ # region D O C U M E N T A T I O N
5
+ """
6
+ Project: MicroCODE Python Packages
7
+ Customer: Internal + MIT xPRO Course
8
+ @module 'mcode_neural.train'
9
+ @memberof mcode_neural
10
+ @created October 2026
11
+ @author Timothy McGuire, MicroCODE, Inc.
12
+ @description >
13
+ MicroCODE Shared Training Library
14
+
15
+ LICENSE:
16
+ --------
17
+ MIT License: MicroCODE.mcode-neural
18
+
19
+ Copyright (c) 2026 Timothy McGuire, MicroCODE, Inc.
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
38
+
39
+
40
+ DESCRIPTION:
41
+ ------------
42
+ This module implements MicroCODE's training loop for our models, one epoch
43
+ loop for every Model, a single neuron or a PyTorch network, classification,
44
+ regression, and autoencoder: each epoch trains on the model's 'train'
45
+ phase and scores every other phase through the Model's own step, keeping
46
+ the weights of the epoch its kind's rule picks on 'val'.
47
+
48
+
49
+ REFERENCES:
50
+ -----------
51
+ 1. MIT xPRO Course: AI and Deep Learning: Neural Network Applications
52
+
53
+ 2. PyTorch -- Transfer Learning for Computer Vision Tutorial (the course's 'train_model()')
54
+ https://pytorch.org/tutorials/beginner/transfer_learning_tutorial.html
55
+
56
+ 3. PyTorch -- torch.optim (optimizers and learning rate schedulers)
57
+ https://pytorch.org/docs/stable/optim.html
58
+ """
59
+
60
+ # endregion
61
+
62
+ # region I M P O R T S
63
+
64
+ import inspect
65
+ import numbers
66
+ import time
67
+
68
+ import copy
69
+ import gc
70
+ import itertools
71
+ import os
72
+ import traceback
73
+
74
+ import torch
75
+
76
+ from .data import data_loaders
77
+
78
+ from .model import HISTORY_DEFAULTS, MODEL_KINDS, _plain
79
+
80
+ from .model import _check_model
81
+
82
+ from .network import NETWORK_KINDS
83
+
84
+ from .model import _out_of_memory
85
+
86
+ from .model import _gpu_memory, model_save
87
+
88
+ from .model import _peaks
89
+
90
+ # endregion
91
+
92
+ # region P R I V A T E F U N C T I O N S
93
+
94
+
95
+ def _check_count(name, count):
96
+ """
97
+ Raises a ValueError naming 'name' unless 'count' is a whole number of at least 1.
98
+
99
+ Args:
100
+ name: the argument being checked, e.g. "num_epochs".
101
+ count: the value to check; NumPy integers are whole numbers, bools are not.
102
+ """
103
+ if isinstance(count, bool) or not isinstance(count, numbers.Integral) or count < 1:
104
+ raise ValueError(
105
+ f"'{name}' must be a whole number of at least 1, got {count!r}"
106
+ )
107
+
108
+
109
+ def _check_phases(model):
110
+ """
111
+ Raises a ValueError unless the model's data has "train" first, a "val" phase, and labels in every phase.
112
+
113
+ Args:
114
+ model: the model to train, its data loaded.
115
+ """
116
+ datasets = model.data.datasets
117
+ phases = list(datasets)
118
+ if not phases or phases[0] != 'train':
119
+ raise ValueError(
120
+ f"'model.data' must start with 'train', so each epoch scores the weights it trained; its phases are {phases}"
121
+ )
122
+ if 'val' not in phases:
123
+ raise ValueError(
124
+ f"'model.data' needs a 'val' phase to pick the best epoch; its phases are {phases}"
125
+ )
126
+ # a phase of new rows without labels can be predicted, but not scored every epoch
127
+ unlabeled = [
128
+ phase
129
+ for phase, dataset in datasets.items()
130
+ if len(dataset) and len(dataset[0]) != 2
131
+ ]
132
+ if unlabeled:
133
+ raise ValueError(
134
+ f"'model.data' phase(s) {unlabeled} have no labels to train or score against"
135
+ )
136
+
137
+
138
+ def _check_keys(name, value, model):
139
+ """
140
+ Raises a TypeError or ValueError naming 'name' unless 'value' is one item, or a dict keyed by 'model.optimizer_keys'.
141
+
142
+ Args:
143
+ name: the argument being checked, "optimizer" or "scheduler".
144
+ value: the caller's optimizer or scheduler.
145
+ model: the Model to train; its 'optimizer_keys' are () for one optimizer.
146
+ """
147
+ owner = f"'{type(model).__name__}.optimizer_keys'"
148
+ keys = list(model.optimizer_keys)
149
+ if not keys:
150
+ if isinstance(value, dict):
151
+ raise TypeError(
152
+ f"'{name}' must be one {name} for a {type(model).__name__}, which names no"
153
+ f' {owner}, got a dict of {list(value)}'
154
+ )
155
+ return
156
+ if not isinstance(value, dict):
157
+ raise TypeError(
158
+ f"'{name}' must be a dict of one {name} per key of {owner} {keys}, got {type(value).__name__}"
159
+ )
160
+ missing = [key for key in keys if key not in value]
161
+ extra = [key for key in value if key not in keys]
162
+ if missing or extra:
163
+ raise ValueError(
164
+ f"'{name}' must have exactly the keys of {owner} {keys}; missing {missing}, extra {extra}"
165
+ )
166
+
167
+
168
+ def _each(value, function):
169
+ """
170
+ Returns function(value) for one item, or {key: function(item)} for a dict of them, e.g. one optimizer per network.
171
+
172
+ Args:
173
+ value: one optimizer or scheduler, or a dict of them keyed by 'optimizer_keys'.
174
+ function: applied to each one.
175
+ """
176
+ if isinstance(value, dict):
177
+ return {key: function(item) for key, item in value.items()}
178
+ return function(value)
179
+
180
+
181
+ def _optimizer_record(optimizer):
182
+ """
183
+ Returns an optimizer's settings for a "train" event: its name, the constructor's settings given, then its defaults.
184
+
185
+ Args:
186
+ optimizer: a PyTorch optimizer or a NeuronOptimizer; both hold 'defaults' and 'param_groups'.
187
+
188
+ Returns:
189
+ {"name", the settings given by the constructor's own names, "defaults": {the rest}}, and
190
+ "param_groups" when a group's setting differs from the constructor's, e.g. a decayed 'lr'.
191
+ """
192
+ # the constructor's settings by its own names: given, or left at the default its signature names
193
+ signature = inspect.signature(type(optimizer)).parameters
194
+ settings = _plain(optimizer.defaults, 'hyperparams.optimizer')
195
+ given = {
196
+ key: value
197
+ for key, value in settings.items()
198
+ if key in signature and value != signature[key].default
199
+ }
200
+ record = {
201
+ 'name': type(optimizer).__name__,
202
+ **given,
203
+ HISTORY_DEFAULTS: {
204
+ key: value for key, value in settings.items() if key not in given
205
+ },
206
+ }
207
+ # a group's setting only where it differs from the constructor's, e.g. a decayed 'lr'
208
+ groups = [
209
+ {
210
+ key: value
211
+ for key, value in _plain(
212
+ {key: group[key] for key in settings},
213
+ f'hyperparams.optimizer.param_groups[{i}]',
214
+ ).items()
215
+ if value != settings[key]
216
+ }
217
+ for i, group in enumerate(optimizer.param_groups)
218
+ ]
219
+ if any(groups):
220
+ record['param_groups'] = groups
221
+ return record
222
+
223
+
224
+ def _schedule_record(scheduler):
225
+ """Returns a scheduler's name and public 'state_dict()' for a "train" event; torch's own '_' keys are its internals."""
226
+ state = scheduler.state_dict()
227
+ return {
228
+ 'name': type(scheduler).__name__,
229
+ **{key: value for key, value in state.items() if not key.startswith('_')},
230
+ }
231
+
232
+
233
+ def _limit_text(device):
234
+ """
235
+ Returns the GPU memory this process may use, as a report names it: "the 6.00 GiB cap", or "the GPU's 8.00 GiB".
236
+
237
+ Args:
238
+ device: a CUDA device.
239
+ """
240
+ total, cap = _gpu_memory(device)
241
+ return f'the {cap:.2f} GiB cap' if cap is not None else f"the GPU's {total:.2f} GiB"
242
+
243
+
244
+ def _peak_text(peaks, device):
245
+ """
246
+ Returns peaks as one line reads them, e.g. "3.48 GiB allocated, 5.07 GiB reserved of the 6.00 GiB cap".
247
+
248
+ Args:
249
+ peaks: '_peaks()', or results holding its two keys.
250
+ device: the CUDA device they were measured on.
251
+ """
252
+ return (
253
+ f'{peaks["peak_allocated_gib"]:.2f} GiB allocated,'
254
+ f' {peaks["peak_reserved_gib"]:.2f} GiB reserved of {_limit_text(device)}'
255
+ )
256
+
257
+
258
+ def _memory(device):
259
+ """
260
+ Returns a network run's memory for its "train" event: its device, its cap, and its peaks, all but the device None on a CPU.
261
+
262
+ Args:
263
+ device: the network's device.
264
+ """
265
+ if device.type != 'cuda':
266
+ return {
267
+ 'device': str(device),
268
+ 'cap_gib': None,
269
+ 'peak_allocated_gib': None,
270
+ 'peak_reserved_gib': None,
271
+ }
272
+ _, cap = _gpu_memory(device)
273
+ return {
274
+ 'device': str(device),
275
+ 'cap_gib': None if cap is None else round(cap, 3),
276
+ **_peaks(device),
277
+ }
278
+
279
+
280
+ def _release(model, device):
281
+ """
282
+ Gives back the GPU memory a network no longer needs: every parameter's gradient, and PyTorch's cached blocks.
283
+
284
+ Args:
285
+ model: the network.
286
+ device: its device; on a CPU only the gradients are dropped; None, a NumPy neuron's,
287
+ holds nothing to release.
288
+ """
289
+ if device is None:
290
+ return
291
+ model.zero_grad(set_to_none=True)
292
+ if device.type == 'cuda':
293
+ # a model and its Networks refer to each other, so a discarded one is freed only when collected
294
+ gc.collect()
295
+ torch.cuda.empty_cache()
296
+
297
+
298
+ def _clear_frames(error):
299
+ """
300
+ Clears the local variables of every finished frame in an error's traceback, and in each error it was raised from.
301
+
302
+ Args:
303
+ error: the error a run raised.
304
+
305
+ Note:
306
+ A Notebook keeps the last traceback, and its frames hold the failed batch's tensors, so the
307
+ GPU memory they use is freed only when the frames are cleared; '%debug' then shows no locals.
308
+ """
309
+ while error is not None:
310
+ traceback.clear_frames(error.__traceback__)
311
+ error = error.__context__
312
+
313
+
314
+ def _save_whole(model, path):
315
+ """
316
+ Saves 'model' with 'model_save()' to 'path' + ".tmp", then replaces 'path' with it, so a crash mid-write keeps the last file.
317
+
318
+ Args:
319
+ model: the model to save.
320
+ path: the checkpoint file.
321
+ """
322
+ temporary = f'{os.fspath(path)}.tmp'
323
+ model_save(model, temporary)
324
+ os.replace(temporary, path)
325
+
326
+
327
+ def _to_cpu(value):
328
+ """
329
+ Returns a copy of 'value' with every tensor in it copied to system RAM, e.g. an optimizer's 'state_dict()'.
330
+
331
+ Args:
332
+ value: a tensor, or a dict, list, or tuple holding tensors and plain values.
333
+ """
334
+ if isinstance(value, torch.Tensor):
335
+ return value.detach().to('cpu', copy=True)
336
+ if isinstance(value, dict):
337
+ return {key: _to_cpu(item) for key, item in value.items()}
338
+ if isinstance(value, (list, tuple)):
339
+ return type(value)(_to_cpu(item) for item in value)
340
+ return copy.deepcopy(value)
341
+
342
+
343
+ def _run_phase(model, loader, phase, optimizer):
344
+ """
345
+ Runs one pass over one phase's loader through the Model's own step, training only in the "train" phase.
346
+
347
+ Args:
348
+ model: the Model, which moves each batch to its own device.
349
+ loader: the phase's DataLoader of (inputs, labels) batches.
350
+ phase: the phase's name, its key in 'model.data.datasets'.
351
+ optimizer: what 'model.train_step()' steps, once per batch in the "train" phase.
352
+
353
+ Returns:
354
+ {metric: value}, each metric averaged over every sample the loader served.
355
+ """
356
+ name = f"'model.data' phase {phase!r}"
357
+ metrics = MODEL_KINDS[model.kind]['metrics']
358
+ totals = dict.fromkeys(metrics, 0.0)
359
+ samples = 0
360
+
361
+ for inputs, labels in loader:
362
+ # S T E P: the Model's own, trained in the "train" phase only, its totals over the batch
363
+ if phase == 'train':
364
+ step = model.train_step(inputs, labels, optimizer, name)
365
+ else:
366
+ step = model.score_step(inputs, labels, name)
367
+ # a step that scores other metrics would chart curves its kind does not name
368
+ if tuple(step) != metrics:
369
+ raise ValueError(
370
+ f"'{type(model).__name__}' scored {list(step)} in {name};"
371
+ f' its kind {model.kind!r} scores {list(metrics)}, in that order'
372
+ )
373
+
374
+ # T A L L Y: each batch's totals, so the phase average weighs every sample alike
375
+ samples += len(inputs)
376
+ for metric, total in step.items():
377
+ totals[metric] += total
378
+
379
+ if not samples:
380
+ raise ValueError(f'{name} served no samples')
381
+ return {metric: total / samples for metric, total in totals.items()}
382
+
383
+
384
+ def _train(model, num_epochs, run_phase, hyperparams, device=None, checkpoint=None):
385
+ """
386
+ Runs the epoch loop every Model shares, then keeps the weights of the val epoch its kind's 'is_better' picks last.
387
+
388
+ Args:
389
+ model: the model, its data loaded and checked by '_check_phases()'.
390
+ num_epochs: the number of epochs, already checked.
391
+ run_phase: run_phase(phase) -> {metric: value}; trains in the "train" phase only.
392
+ hyperparams: the trainer's settings, plain values from '_plain()', for the "train" event.
393
+ device: a network's device, whose memory the run records, and on CUDA prints and
394
+ releases; None for a single neuron, which records none.
395
+ checkpoint: a file the model is saved to at each new best epoch and at the end; None
396
+ saves nothing.
397
+
398
+ Returns:
399
+ the same 'model', its best val epoch's weights loaded, with a "train" event appended to
400
+ its history: 'hyperparams', and the results {"curves", "best_epoch",
401
+ f"best_val_{metric}", "seconds", "tests": {}}, so 'model.curves' and 'model.best_epoch'
402
+ read this run; a network's results add "device", "cap_gib", "peak_allocated_gib", and
403
+ "peak_reserved_gib" after "seconds".
404
+
405
+ Note:
406
+ An interrupted or failed run, e.g. Interrupt Kernel or an out-of-memory error, puts back
407
+ the weights the model had when it started, records no event, and raises again. A
408
+ checkpoint written before then keeps its best epoch, its last "train" event the run so
409
+ far, with "status": "in process".
410
+ """
411
+ # S E T U P: the model kind's metrics, one empty curve per phase and metric, no best epoch yet
412
+ model_kind = MODEL_KINDS[model.kind]
413
+ phases = list(model.data.datasets)
414
+ width = max(len(phase) for phase in phases)
415
+ training_curves = {
416
+ f'{phase}_{metric}': [] for phase in phases for metric in model_kind['metrics']
417
+ }
418
+ best = None
419
+ start = time.perf_counter()
420
+
421
+ # H I S T O R Y: this run's settings and results, one event, so an earlier run's stay as they were
422
+ def results(seconds):
423
+ return {
424
+ 'curves': training_curves,
425
+ 'best_epoch': best_epoch,
426
+ f'best_val_{model_kind["best"]}': best,
427
+ 'seconds': round(seconds, 3),
428
+ **({} if device is None else _memory(device)),
429
+ 'tests': {},
430
+ }
431
+
432
+ # S T A R T: the weights before this run, in system RAM, put back if the run is interrupted or fails
433
+ start_weights = model.weights()
434
+ try:
435
+ # E P O C H L O O P: train, then score every phase, keeping the best val epoch's weights
436
+ for epoch in range(num_epochs):
437
+ print(f'\nEpoch {epoch}/{num_epochs - 1}')
438
+ print('-' * 10)
439
+
440
+ for phase in phases:
441
+ values = run_phase(phase)
442
+ for metric, value in values.items():
443
+ training_curves[f'{phase}_{metric}'].append(value)
444
+ scores = ' '.join(
445
+ f'{metric.title()}: {value:.4f}' for metric, value in values.items()
446
+ )
447
+ print(f'{phase:{width}} {scores}')
448
+
449
+ if phase == 'val':
450
+ score = values[model_kind['best']]
451
+ if best is None or model_kind['is_better'](score, best):
452
+ best = score
453
+ best_epoch = epoch
454
+ best_weights = model.weights()
455
+
456
+ # C H E C K P O I N T: at an epoch's end, a new best is saved with the run so far, in the file only
457
+ if checkpoint is not None and best_epoch == epoch:
458
+ so_far = results(time.perf_counter() - start)
459
+ model._record('train', hyperparams, so_far | {'status': 'in process'})
460
+ try:
461
+ _save_whole(model, checkpoint)
462
+ finally:
463
+ model.history.pop()
464
+ except BaseException:
465
+ model.load_weights(start_weights)
466
+ raise
467
+
468
+ # B E S T E P O C H: report the run's time and best score, then restore that epoch's weights
469
+ elapsed = time.perf_counter() - start
470
+ minutes, seconds = divmod(round(elapsed), 60)
471
+ model.load_weights(best_weights)
472
+ finished = results(elapsed)
473
+ print(f'\nTraining complete in {minutes}m {seconds}s')
474
+ if device is not None and device.type == 'cuda':
475
+ print(f'Peak GPU memory: {_peak_text(finished, device)}')
476
+ _release(model, device)
477
+ held = torch.cuda.memory_reserved(device) / 2**30
478
+ print(f'GPU memory held: {held:.2f} GiB')
479
+ print(
480
+ f'\nBEST val {model_kind["best"].title()}: {best:.4f} at epoch {best_epoch}\n\n'
481
+ )
482
+
483
+ model._record('train', hyperparams, finished)
484
+ if checkpoint is not None:
485
+ _save_whole(model, checkpoint)
486
+
487
+ return model
488
+
489
+
490
+ # endregion
491
+
492
+ # region P U B L I C F U N C T I O N S
493
+
494
+
495
+ def train_model(model, optimizer, scheduler, num_epochs, batch_size, checkpoint=None):
496
+ """
497
+ Trains a Model on its own data, scoring every phase each epoch, and keeps its best val epoch's weights.
498
+
499
+ Args:
500
+ model: the Model to train, its data loaded, e.g. ClassificationModel,
501
+ BinaryClassificationModel, RegressionModel, AutoencoderModel, or a single
502
+ neuron such as LogisticClassifierNeuronModel; its own 'train_step()' and
503
+ 'score_step()' make its losses, step its optimizer, and score each batch.
504
+ optimizer: the optimizer of 'model.parameters()', e.g. torch.optim.Adam; a
505
+ NeuronOptimizer for a single neuron; or, for a Model that names
506
+ 'optimizer_keys', a dict of one optimizer per key, e.g.
507
+ {"generator": ..., "discriminator": ...}.
508
+ scheduler: steps once per epoch, after the "train" phase, e.g. ExponentialLR; None for
509
+ no schedule. For a Model that names 'optimizer_keys', a dict of one
510
+ scheduler per key. PyTorch's schedulers take only PyTorch optimizers.
511
+ num_epochs: the number of epochs, a whole number of at least 1.
512
+ batch_size: the number of samples in each batch, a whole number of at least 1.
513
+ checkpoint: a file path, e.g. "../Models/vgg11.pt", that the model is saved to with
514
+ 'model_save()' at each epoch whose val score is the best so far, and at the
515
+ end; its folder must exist. None, the default, saves nothing.
516
+
517
+ Returns:
518
+ the same 'model', its best val epoch's weights loaded, a network in eval() mode, with
519
+ 'model.curves' = {f"{phase}_{metric}": [one float per epoch]} and that epoch's number
520
+ in 'model.best_epoch', as 'show_training_curves(model)' reads them, both from the
521
+ "train" event this run appends to 'model.history'.
522
+
523
+ Note:
524
+ The phases are those of 'model.data': "train" first, then "val", which picks the best
525
+ epoch, and any others, e.g. "test", scored every epoch; each is served by an unshuffled
526
+ DataLoader of 'batch_size', as 'data_loaders()' makes.
527
+ A classifier trains on nn.CrossEntropyLoss of its logits, scores "loss" and "acc", and
528
+ keeps its highest val acc; a regressor trains on nn.MSELoss, scores "loss" only, its
529
+ (N, 1) output flattened to (N,) targets, and keeps its lowest val loss.
530
+ A binary classifier trains on nn.BCEWithLogitsLoss: its labels, (N,) for one output or
531
+ (N, k) for k, must be 0 or 1 and are cast to the model's float dtype, so integer labels
532
+ train as they are; it predicts 'logit > 0', its "acc" is the share of (sample, label)
533
+ answers that are right, and it keeps its highest val acc.
534
+ An autoencoder trains on nn.MSELoss of each sample against itself, scores
535
+ "loss" only, and keeps its lowest val loss; its labels only serve the batches, and every
536
+ input must lie in its output style's 'output_range'.
537
+ A single neuron trains with a NeuronOptimizer on its own loss ('loss()' and
538
+ 'loss_gradient()'): each "train" batch is scored, then the neuron steps by the mean of
539
+ its samples' updates, so 'batch_size=1' is the course's per-sample loop. Its labels
540
+ must be one per sample, each one of its 'label_value'. A classifier neuron scores
541
+ "loss" and "acc" and keeps its highest val acc, predicting its higher label where a is
542
+ above its threshold; a regressor neuron scores "loss" only and keeps its lowest val
543
+ loss. A neuron has no device, so its results record no memory.
544
+ A Model of several networks, e.g. a GAN, names its optimizers' keys in
545
+ 'optimizer_keys', and a missing or extra key fails naming it; its step returns one
546
+ total per metric of its kind, e.g. "dloss" and "gloss", and a kind whose 'is_better'
547
+ always holds keeps its last epoch.
548
+ The first epoch always counts as the best so far; a later epoch replaces it only by
549
+ scoring strictly better, so the earliest of equal epochs is kept. Each batch is moved
550
+ to the model's own device and shaped as the network reads each sample, its 'in_shape':
551
+ one row for a Linear network, one (channels, height, width) image for a network with
552
+ conv stages. Epochs are numbered from 0 in every printed line.
553
+ The "train" event records the optimizer's name and its constructor's settings by their
554
+ own names, e.g. "lr": the ones given, then those left at their defaults under
555
+ "defaults", which 'show_model_history()' marks "(default)"; a setting the optimizer
556
+ class fixes itself, e.g. AdamW's 'decoupled_weight_decay', reads as a default. A param
557
+ group's setting is recorded, under "param_groups", only where it differs from the
558
+ constructor's when the run starts, e.g. a decayed 'lr'. It also records the scheduler's
559
+ name and public 'state_dict()' (or None), 'num_epochs', and 'batch_size'; training
560
+ again appends another event, so every run is kept. A dict of optimizers is recorded
561
+ by its keys, {key: record}, and a dict of schedulers the same way.
562
+ A batch that runs past the GPU memory this process may use, the cap 'model_device()'
563
+ set or the GPU's whole memory, raises torch.OutOfMemoryError naming the phase and
564
+ 'batch_size', e.g. "phase 'train' at batch_size=100 needs more than the 6.00 GiB cap;
565
+ try a smaller batch_size"; PyTorch's own error is its cause.
566
+ The "train" event's results also record the run's "device", e.g. "cuda:0", the cap
567
+ 'model_device()' set in GiB ("cap_gib", None with no cap), and its peak GPU memory in
568
+ GiB, "peak_allocated_gib" and "peak_reserved_gib" (None on a CPU). On CUDA it prints,
569
+ after the run's time, "Peak GPU memory: 3.48 GiB allocated, 5.07 GiB reserved of the
570
+ 6.00 GiB cap" and, once released, "GPU memory held: 0.55 GiB".
571
+ However the run ends, every parameter's gradient is dropped and PyTorch's cached GPU
572
+ blocks are returned to the driver. An interrupted or failed run, e.g. Interrupt Kernel
573
+ or an out-of-memory error, records no event and puts back the weights the model had when
574
+ it started; its traceback's local variables are cleared, so the failed batch leaves the
575
+ GPU ('%debug' shows no locals). The optimizer's state, e.g. Adam's two moments per
576
+ weight, is the caller's: free it with 'del optimizer', or build a new optimizer for the
577
+ next phase.
578
+ A checkpoint is written to "<checkpoint>.tmp" and then replaces the file, so a crash
579
+ mid-write keeps the last best. Until the run ends its history's last "train" event is
580
+ the run so far, with "status": "in process", which 'show_model_history()' reads "In
581
+ process..."; the finished run rewrites it with the finished event. 'model_load()' reads
582
+ it as any saved model.
583
+
584
+ Example:
585
+ >>> model.data = data_frame_split(df, input_columns, "color", torch.int64,
586
+ ... input_columns, wine_classes, seed=42)
587
+ >>> optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
588
+ >>> scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.95)
589
+ >>> model = train_model(model, optimizer, scheduler, num_epochs=50, batch_size=10)
590
+ >>> show_training_curves(model)
591
+ >>> neuron = train_model(neuron, NeuronOptimizer(lr=0.01), None, num_epochs=10, batch_size=1)
592
+ """
593
+ # V A L I D A T E: the model, its phases, the counts, and its optimizers fail by name before training
594
+ _check_model(model)
595
+ _check_phases(model)
596
+ _check_count('num_epochs', num_epochs)
597
+ _check_count('batch_size', batch_size)
598
+ _check_keys('optimizer', optimizer, model)
599
+ if scheduler is not None:
600
+ _check_keys('scheduler', scheduler, model)
601
+ if checkpoint is not None:
602
+ if not isinstance(checkpoint, (str, os.PathLike)):
603
+ raise TypeError(
604
+ f'\'checkpoint\' must be a file path, e.g. "../Models/vgg11.pt", got {type(checkpoint).__name__}'
605
+ )
606
+ folder = os.path.dirname(os.path.abspath(checkpoint))
607
+ if not os.path.isdir(folder):
608
+ raise ValueError(
609
+ f"'checkpoint' {os.fspath(checkpoint)!r} is in a folder that does not exist: {folder!r}"
610
+ )
611
+
612
+ # H Y P E R P A R A M E T E R S: this run's settings, made plain now, so one a saved model
613
+ # cannot keep fails by name before training; torch's own '_' keys are its internals
614
+ hyperparams = _plain(
615
+ {
616
+ 'trainer': 'train_model',
617
+ 'optimizer': _each(optimizer, _optimizer_record),
618
+ 'scheduler': None
619
+ if scheduler is None
620
+ else _each(scheduler, _schedule_record),
621
+ 'num_epochs': num_epochs,
622
+ 'batch_size': batch_size,
623
+ 'checkpoint': None if checkpoint is None else os.fspath(checkpoint),
624
+ },
625
+ 'hyperparams',
626
+ )
627
+
628
+ # S E T U P: one loader per phase, and the device its network kind trains on, None for NumPy
629
+ loaders = data_loaders(model.data.datasets, batch_size)
630
+ device = NETWORK_KINDS[model.network_kind]['device'](model)
631
+
632
+ def run_phase(phase):
633
+ try:
634
+ values = _run_phase(model, loaders[phase], phase, optimizer)
635
+ except torch.OutOfMemoryError as error:
636
+ raise _out_of_memory(phase, batch_size, device) from error
637
+ if phase == 'train' and scheduler is not None:
638
+ _each(scheduler, lambda item: item.step())
639
+ return values
640
+
641
+ # M E M O R Y: the peak is this run's; whatever ends it, its gradients and cached blocks go back
642
+ if device is not None and device.type == 'cuda':
643
+ torch.cuda.reset_peak_memory_stats(device)
644
+ try:
645
+ return _train(model, num_epochs, run_phase, hyperparams, device, checkpoint)
646
+ except BaseException as error:
647
+ _clear_frames(error)
648
+ raise
649
+ finally:
650
+ _release(model, device)
651
+
652
+
653
+ def train_probe(model, optimizer, batch_sizes, num_batches=2):
654
+ """
655
+ Measures the peak GPU memory of training a network at each batch size, smallest first, and leaves it as it was.
656
+
657
+ Args:
658
+ model: the network to probe, on a CUDA device, its data loaded, e.g. ClassificationModel.
659
+ optimizer: the optimizer the run will use, of 'model.parameters()', or the dict of
660
+ them a Model that names 'optimizer_keys' takes; it steps, so its state,
661
+ e.g. Adam's two moments per weight, counts in each peak.
662
+ batch_sizes: the batch sizes to try, e.g. [32, 64, 100], each a whole number of at least 1
663
+ and at most the size of the "train" phase.
664
+ num_batches: the training batches run at each size, a whole number of at least 1.
665
+
666
+ Returns:
667
+ {batch_size: {"peak_allocated_gib", "peak_reserved_gib"}}, smallest first, in GiB to 3
668
+ places; the first size that does not fit maps to None, and no larger size is tried.
669
+
670
+ Note:
671
+ Each size trains 'num_batches' batches of the "train" phase as 'train_model()' does,
672
+ through the Model's own 'train_step()'. Reserved is what the cap of
673
+ 'model_device()' bounds. One line is printed per size, e.g. "batch_size 64: 5.00 GiB
674
+ allocated, 5.56 GiB reserved of the 6.00 GiB cap" or "batch_size 100: does not fit the
675
+ 6.00 GiB cap". Afterwards the model's weights, the optimizer's state, and torch's random
676
+ state are put back, its gradients and PyTorch's cached GPU blocks are released, and
677
+ nothing is recorded in its history; the scheduler is not used.
678
+
679
+ Example:
680
+ >>> model = ClassificationModel(...).to(model_device())
681
+ >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
682
+ >>> train_probe(model, optimizer, [32, 48, 64, 100])
683
+ >>> model = train_model(model, optimizer, None, num_epochs=10, batch_size=64)
684
+ """
685
+ # V A L I D A T E: the network, its data, the sizes, and its device fail by name before any batch
686
+ _check_model(model)
687
+ _check_phases(model)
688
+ _check_keys('optimizer', optimizer, model)
689
+ if not isinstance(batch_sizes, (list, tuple)) or not batch_sizes:
690
+ raise ValueError(
691
+ f"'batch_sizes' must be a list of whole numbers, e.g. [32, 64, 100], got {batch_sizes!r}"
692
+ )
693
+ dataset = model.data.datasets['train']
694
+ for i, batch_size in enumerate(batch_sizes):
695
+ _check_count(f'batch_sizes[{i}]', batch_size)
696
+ if batch_size > len(dataset):
697
+ raise ValueError(
698
+ f"'batch_sizes[{i}]' is {batch_size}, more than the {len(dataset)} samples of the 'train' phase"
699
+ )
700
+ _check_count('num_batches', num_batches)
701
+ device = NETWORK_KINDS[model.network_kind]['device'](model)
702
+ if device is None:
703
+ raise ValueError(
704
+ f"'model' is a {type(model).__name__}, which has no device; 'train_probe()' measures"
705
+ ' the GPU memory of a PyTorch network'
706
+ )
707
+ if device.type != 'cuda':
708
+ raise ValueError(
709
+ f"'model' is on the {device}; 'train_probe()' measures GPU memory, so move it to the GPU"
710
+ " first, e.g. 'model.to(model_device())'"
711
+ )
712
+
713
+ # S T A R T: what the probe puts back, kept in system RAM so it adds nothing to a peak
714
+ start_weights = model.weights()
715
+ # each optimizer with the state it is put back to
716
+ start_states = _each(optimizer, lambda item: (item, _to_cpu(item.state_dict())))
717
+ was_training = model.training
718
+ width = len(str(max(batch_sizes)))
719
+ peaks = {}
720
+
721
+ # P R O B E: each size from a released GPU, its peak alone; the first that does not fit ends it
722
+ try:
723
+ with torch.random.fork_rng(devices=[device.index]):
724
+ for batch_size in sorted(batch_sizes):
725
+ _release(model, device)
726
+ torch.cuda.reset_peak_memory_stats(device)
727
+ loader = data_loaders({'train': dataset}, batch_size)['train']
728
+ try:
729
+ _run_phase(
730
+ model,
731
+ itertools.islice(loader, num_batches),
732
+ 'train',
733
+ optimizer,
734
+ )
735
+ except torch.OutOfMemoryError as error:
736
+ _clear_frames(error)
737
+ peaks[batch_size] = None
738
+ print(
739
+ f'batch_size {batch_size:>{width}}: does not fit {_limit_text(device)}'
740
+ )
741
+ break
742
+ peaks[batch_size] = _peaks(device)
743
+ print(
744
+ f'batch_size {batch_size:>{width}}: {_peak_text(peaks[batch_size], device)}'
745
+ )
746
+ finally:
747
+ model.load_weights(start_weights)
748
+ _each(start_states, lambda start: start[0].load_state_dict(start[1]))
749
+ model.train(was_training)
750
+ _release(model, device)
751
+ return peaks
752
+
753
+
754
+ # endregion
755
+ # endregion
756
+ # endregion