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/__init__.py +183 -0
- mcode_neural/data.py +773 -0
- mcode_neural/fonts/MicroCODE-Mono/MicroCODE-Mono.ttf +0 -0
- mcode_neural/fonts/MicroCODE-Mono/OFL.txt +128 -0
- mcode_neural/fonts/RobotoMono/OFL.txt +93 -0
- mcode_neural/fonts/RobotoMono/RobotoMono-Bold.ttf +0 -0
- mcode_neural/fonts/RobotoMono/RobotoMono-Bold.woff2 +0 -0
- mcode_neural/fonts/RobotoMono/RobotoMono-Regular.ttf +0 -0
- mcode_neural/fonts/RobotoMono/RobotoMono-Regular.woff2 +0 -0
- mcode_neural/layer.py +589 -0
- mcode_neural/model.py +2092 -0
- mcode_neural/network.py +447 -0
- mcode_neural/neuron.py +233 -0
- mcode_neural/predict.py +360 -0
- mcode_neural/show.py +6076 -0
- mcode_neural/train.py +756 -0
- mcode_neural-0.5.0.dist-info/METADATA +767 -0
- mcode_neural-0.5.0.dist-info/RECORD +22 -0
- mcode_neural-0.5.0.dist-info/WHEEL +4 -0
- mcode_neural-0.5.0.dist-info/licenses/LICENSE +21 -0
- mcode_neural-0.5.0.dist-info/licenses/src/mcode_neural/fonts/MicroCODE-Mono/OFL.txt +128 -0
- mcode_neural-0.5.0.dist-info/licenses/src/mcode_neural/fonts/RobotoMono/OFL.txt +93 -0
mcode_neural/model.py
ADDED
|
@@ -0,0 +1,2092 @@
|
|
|
1
|
+
# region F I L E
|
|
2
|
+
# <copyright file="mcode-neural/src/mcode_neural/model.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.model'
|
|
9
|
+
@memberof mcode_neural
|
|
10
|
+
@created October 2026
|
|
11
|
+
@author Timothy McGuire, MicroCODE, Inc.
|
|
12
|
+
@description >
|
|
13
|
+
MicroCODE Shared Self-Describing Model 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 every MicroCODE model: the 'Model' base class, with
|
|
43
|
+
its 'kind', the arguments it was built with, its 'Data', its training history,
|
|
44
|
+
and its 'network' list, each a 'Network' of 'Layer's; the 'MODEL_KINDS' each
|
|
45
|
+
'kind' selects; our single neuron models, 'NeuronModel' and its regressor and
|
|
46
|
+
classifiers, in NumPy; our multi-layer models, 'FeedforwardModel' and its
|
|
47
|
+
classification, binary classification, and regression models, and
|
|
48
|
+
'AutoencoderModel', in PyTorch; a network built elsewhere, e.g. VGG11,
|
|
49
|
+
wrapped as one of them; saving and loading a model with all of it;
|
|
50
|
+
the device a model trains on, its GPU memory capped; and freezing, unfreezing,
|
|
51
|
+
and replacing the output layer of any network, for transfer learning.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
REFERENCES:
|
|
55
|
+
-----------
|
|
56
|
+
1. MIT xPRO Course: AI and Deep Learning: Neural Network Applications
|
|
57
|
+
|
|
58
|
+
2. PyTorch -- Serialization semantics (torch.save, torch.load with weights_only)
|
|
59
|
+
https://pytorch.org/docs/stable/notes/serialization.html
|
|
60
|
+
|
|
61
|
+
3. PyTorch -- torch.nn (Module, Sequential)
|
|
62
|
+
https://pytorch.org/docs/stable/nn.html
|
|
63
|
+
|
|
64
|
+
4. PyTorch -- torch.cuda.set_per_process_memory_fraction (the GPU memory cap)
|
|
65
|
+
https://pytorch.org/docs/stable/generated/torch.cuda.set_per_process_memory_fraction.html
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# endregion
|
|
69
|
+
|
|
70
|
+
# region I M P O R T S
|
|
71
|
+
|
|
72
|
+
import inspect
|
|
73
|
+
import operator
|
|
74
|
+
|
|
75
|
+
import re
|
|
76
|
+
|
|
77
|
+
import math
|
|
78
|
+
|
|
79
|
+
import numbers
|
|
80
|
+
from contextlib import contextmanager
|
|
81
|
+
from datetime import datetime
|
|
82
|
+
from importlib.metadata import version
|
|
83
|
+
|
|
84
|
+
from typing import ClassVar
|
|
85
|
+
|
|
86
|
+
import numpy as np
|
|
87
|
+
import torch
|
|
88
|
+
|
|
89
|
+
from torch import nn
|
|
90
|
+
|
|
91
|
+
from .data import DATA_KINDS, Data
|
|
92
|
+
|
|
93
|
+
from .layer import (
|
|
94
|
+
_check_conv,
|
|
95
|
+
_check_dropout,
|
|
96
|
+
_check_sizes,
|
|
97
|
+
_check_width,
|
|
98
|
+
_conv_arguments,
|
|
99
|
+
_conv_stack,
|
|
100
|
+
_deconv_stack,
|
|
101
|
+
_sample_shape,
|
|
102
|
+
_scored,
|
|
103
|
+
_shaped,
|
|
104
|
+
_stack,
|
|
105
|
+
_stage_shapes,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
from .network import NETWORK_KINDS
|
|
109
|
+
from .neuron import NEURON_KINDS, NON_ZERO
|
|
110
|
+
|
|
111
|
+
from .neuron import NeuronOptimizer
|
|
112
|
+
|
|
113
|
+
# endregion
|
|
114
|
+
|
|
115
|
+
# region C O N S T A N T S
|
|
116
|
+
|
|
117
|
+
# what every model of one kind shares, keyed by the model's 'kind':
|
|
118
|
+
# metrics: the curves its trainer keeps per phase; "acc" is the share of answers it gets right
|
|
119
|
+
# best / is_better: the val metric that picks the best epoch, and its strict comparison
|
|
120
|
+
# has_classes: the answers allowed to "does its data name its classes, {label: (color, name)}?"
|
|
121
|
+
# a kind whose 'is_better' always holds keeps its last epoch; a metric name holds no '_', since
|
|
122
|
+
# 'show_training_curves()' splits each curve's key at its last '_', e.g. "dloss" and "gloss"
|
|
123
|
+
MODEL_KINDS = {
|
|
124
|
+
'classification': {
|
|
125
|
+
'metrics': ('loss', 'acc'),
|
|
126
|
+
'best': 'acc',
|
|
127
|
+
'is_better': operator.gt,
|
|
128
|
+
'has_classes': (True,),
|
|
129
|
+
},
|
|
130
|
+
'regression': {
|
|
131
|
+
'metrics': ('loss',),
|
|
132
|
+
'best': 'loss',
|
|
133
|
+
'is_better': operator.lt,
|
|
134
|
+
'has_classes': (False,),
|
|
135
|
+
},
|
|
136
|
+
'autoencoder': {
|
|
137
|
+
'metrics': ('loss',),
|
|
138
|
+
'best': 'loss',
|
|
139
|
+
'is_better': operator.lt,
|
|
140
|
+
'has_classes': (True, False),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# the attributes 'Model' sets on every model, apart from each family's learned state
|
|
145
|
+
MODEL_ATTRIBUTES = ('_data', 'history', '_network')
|
|
146
|
+
|
|
147
|
+
# the versions every "create" event records, read once, as plain strings a saved model can keep
|
|
148
|
+
MODEL_VERSIONS = {
|
|
149
|
+
'mcode_neural': version('mcode-neural'),
|
|
150
|
+
'torch': str(torch.__version__),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
SEED_LIMIT = 2**32 - 1
|
|
154
|
+
|
|
155
|
+
# the key under which a step's hyperparams keep the settings it was not given; 'show_model_history()' marks them
|
|
156
|
+
HISTORY_DEFAULTS = 'defaults'
|
|
157
|
+
|
|
158
|
+
# the neuron kinds an autoencoder's decoder output may take, its 'output_activation':
|
|
159
|
+
# linear: standardized rows, or images from Normalize()
|
|
160
|
+
# sigmoid: images from ToTensor() alone, or min-max scaled rows
|
|
161
|
+
# tanh: images from Normalize([0.5], [0.5]), as the course loads MNIST
|
|
162
|
+
AUTOENCODER_OUTPUTS = ('linear', 'sigmoid', 'tanh')
|
|
163
|
+
|
|
164
|
+
# the share of a GPU's memory 'model_device()' lets this process use, the rest left to the desktop
|
|
165
|
+
DEVICE_FRACTION = 0.75
|
|
166
|
+
|
|
167
|
+
# the samples a model scores at once before it has trained; after, its latest training's 'batch_size'
|
|
168
|
+
SCORE_BATCH_SIZE = 64
|
|
169
|
+
|
|
170
|
+
# the history events that change a Model's network, replayed in order by 'model_load()' before its weights load
|
|
171
|
+
MODEL_CHANGES = {
|
|
172
|
+
'replace_output': lambda model, hyperparams: _replace_output(
|
|
173
|
+
model, hyperparams['out_features']
|
|
174
|
+
),
|
|
175
|
+
'freeze': lambda model, hyperparams: _set_trainable(
|
|
176
|
+
model, hyperparams['parts'], False
|
|
177
|
+
),
|
|
178
|
+
'unfreeze': lambda model, hyperparams: _set_trainable(
|
|
179
|
+
model, hyperparams['parts'], True
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# endregion
|
|
184
|
+
|
|
185
|
+
# region C L A S S E S
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Model:
|
|
189
|
+
"""
|
|
190
|
+
A self-describing model, the parent of our single neuron and multi-layer models: its kind,
|
|
191
|
+
the arguments it was built with, the data it reads, and how it was trained.
|
|
192
|
+
|
|
193
|
+
Subclasses set 'kind' ("classification", "regression", or "autoencoder", a key of
|
|
194
|
+
'MODEL_KINDS') and 'hidden_size' (the width of each hidden layer, or a conv stage's
|
|
195
|
+
(channels, height, width); () for none), declare their networks in 'sources' and
|
|
196
|
+
'network_kind', build 'model.network' from that 'NETWORK_KINDS' row, and
|
|
197
|
+
implement 'weights()', 'load_weights()', 'train_step()', 'score_step()', and 'classify()',
|
|
198
|
+
'regress()', or an autoencoder's 'encode()', 'decode()', and 'reconstruct()'. A class that sets no 'kind',
|
|
199
|
+
e.g. 'NeuronModel' or 'FeedforwardModel', is abstract: building one raises a TypeError.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
arguments: the subclass constructor's arguments by name, with its 'seed' when it builds
|
|
203
|
+
its own weights, e.g. {"in_features": 5, "seed": None}, so 'model_load()'
|
|
204
|
+
can build the model again;
|
|
205
|
+
in the order 'show_model_history()' lists them. The "create" event keeps
|
|
206
|
+
those equal to the default the constructor's signature names under
|
|
207
|
+
'HISTORY_DEFAULTS', e.g. {"in_features": 5, "defaults": {"seed": None}}.
|
|
208
|
+
|
|
209
|
+
Note:
|
|
210
|
+
The attribute is named 'kind', not 'type', because 'type' would shadow 'nn.Module.type()', which casts a
|
|
211
|
+
network's parameters.
|
|
212
|
+
'history' is the one record of how the model was made: a list of events, oldest first,
|
|
213
|
+
each {"event", "time", "hyperparams", "results"}. "create" is recorded here, "data" when
|
|
214
|
+
'model.data' is set, "train" by 'train_model()', and "load" by
|
|
215
|
+
'model_load()'; 'model_test()' keeps its metrics in the latest "train" event. 'arguments',
|
|
216
|
+
'curves', and 'best_epoch' are read-only views of it: 'curves' is {} and 'best_epoch' is
|
|
217
|
+
None until the model is trained.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
# every model class by name, so 'model_load()' can build a saved model again
|
|
221
|
+
CLASSES: ClassVar[dict] = {}
|
|
222
|
+
# whether its networks are given to it rather than built from its arguments
|
|
223
|
+
is_external = False
|
|
224
|
+
# the keys of the optimizer dict its 'train_step()' takes; () when it takes one optimizer
|
|
225
|
+
optimizer_keys = ()
|
|
226
|
+
|
|
227
|
+
def __init_subclass__(cls, **kwargs):
|
|
228
|
+
super().__init_subclass__(**kwargs)
|
|
229
|
+
Model.CLASSES[cls.__name__] = cls
|
|
230
|
+
|
|
231
|
+
def __init__(self, arguments):
|
|
232
|
+
if not hasattr(type(self), 'kind'):
|
|
233
|
+
subclasses = [cls.__name__ for cls in type(self).__subclasses__()]
|
|
234
|
+
raise TypeError(
|
|
235
|
+
f"'{type(self).__name__}' is abstract; build one of its subclasses, e.g. {subclasses}"
|
|
236
|
+
)
|
|
237
|
+
# a network's nn.Module.__init__ must run before its first attribute is set
|
|
238
|
+
super().__init__()
|
|
239
|
+
if 'seed' in arguments:
|
|
240
|
+
seed = arguments['seed']
|
|
241
|
+
if seed is not None and (
|
|
242
|
+
isinstance(seed, bool)
|
|
243
|
+
or not isinstance(seed, numbers.Integral)
|
|
244
|
+
or not 0 <= seed <= SEED_LIMIT
|
|
245
|
+
):
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"'seed' must be a whole number from 0 to {SEED_LIMIT}, or None, got {seed!r}"
|
|
248
|
+
)
|
|
249
|
+
self.history = []
|
|
250
|
+
# the arguments given, then those left at the default the subclass's signature names
|
|
251
|
+
signature = inspect.signature(type(self)).parameters
|
|
252
|
+
given = {
|
|
253
|
+
key: value
|
|
254
|
+
for key, value in arguments.items()
|
|
255
|
+
if value != signature[key].default
|
|
256
|
+
}
|
|
257
|
+
hyperparams = {
|
|
258
|
+
**given,
|
|
259
|
+
HISTORY_DEFAULTS: {
|
|
260
|
+
key: value for key, value in arguments.items() if key not in given
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
self._record('create', hyperparams, MODEL_VERSIONS)
|
|
264
|
+
self._data = None
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def arguments(self):
|
|
268
|
+
"""The arguments the model was built with, given and default, by name, from its "create" event; read-only."""
|
|
269
|
+
return _arguments(self.history[0]['hyperparams'])
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def curves(self):
|
|
273
|
+
"""The latest training's {f"{phase}_{metric}": [one value per epoch]}; {} before any training."""
|
|
274
|
+
train = _latest_train(self)
|
|
275
|
+
return {} if train is None else train['results']['curves']
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def best_epoch(self):
|
|
279
|
+
"""The epoch whose weights the latest training kept; None before any training."""
|
|
280
|
+
train = _latest_train(self)
|
|
281
|
+
return None if train is None else train['results']['best_epoch']
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def in_features(self):
|
|
285
|
+
"""The number of values in one sample, its 'in_features' argument; read-only."""
|
|
286
|
+
return self.arguments['in_features']
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def data(self):
|
|
290
|
+
"""The model's 'Data', set once, e.g. 'model.data = data_frame_split(...)'."""
|
|
291
|
+
# a ValueError, as an AttributeError here would be replaced by nn.Module.__getattr__'s own
|
|
292
|
+
if self._data is None:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"'model' has no data; load it first, e.g. 'model.data = data_frame_split(...)'"
|
|
295
|
+
)
|
|
296
|
+
return self._data
|
|
297
|
+
|
|
298
|
+
@data.setter
|
|
299
|
+
def data(self, data):
|
|
300
|
+
if not isinstance(data, Data):
|
|
301
|
+
raise TypeError(
|
|
302
|
+
f"'model.data' must be a Data, e.g. from 'data_frame_split()', got {type(data).__name__}"
|
|
303
|
+
)
|
|
304
|
+
# a classifier's views name its classes; a regressor has none to name; an autoencoder may
|
|
305
|
+
has_classes = data.classes is not None
|
|
306
|
+
if has_classes not in MODEL_KINDS[self.kind]['has_classes']:
|
|
307
|
+
needs = "takes no 'classes' (None)" if has_classes else "needs 'classes'"
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"'model' is {_article(self.kind)} model, so its data {needs}"
|
|
310
|
+
)
|
|
311
|
+
in_features = self.in_features
|
|
312
|
+
if DATA_KINDS[data.kind]['has_features'] and len(data.feature) != in_features:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"'model.data' names {len(data.feature)} features; 'model' reads {in_features}"
|
|
315
|
+
)
|
|
316
|
+
self._data = data
|
|
317
|
+
if self.history[-1]['event'] == 'data':
|
|
318
|
+
self.history.pop()
|
|
319
|
+
samples = {phase: len(dataset) for phase, dataset in data.datasets.items()}
|
|
320
|
+
self._record('data', data.split, samples)
|
|
321
|
+
|
|
322
|
+
def _record(self, event, hyperparams, results):
|
|
323
|
+
"""
|
|
324
|
+
Appends one event to 'history', stamped with the local time, its values plain for 'model_save()'.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
event: the step, "create", "data", "train", or "load".
|
|
328
|
+
hyperparams: what the step was given, {name: value}; None when not recorded.
|
|
329
|
+
results: what the step produced, {name: value}; None when not recorded.
|
|
330
|
+
"""
|
|
331
|
+
time = datetime.now().astimezone().isoformat(sep=' ', timespec='seconds')
|
|
332
|
+
self.history.append(
|
|
333
|
+
_event(
|
|
334
|
+
event,
|
|
335
|
+
time,
|
|
336
|
+
_plain(hyperparams, 'hyperparams'),
|
|
337
|
+
_plain(results, 'results'),
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def network(self):
|
|
343
|
+
"""The model's Networks, in the order its 'sources' lists them, e.g. 'model.network[1].layer[0]'; read-only."""
|
|
344
|
+
return self._network
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def layer(self):
|
|
348
|
+
"""
|
|
349
|
+
The Layers of a model of one network, e.g. 'model.layer[2].neuron[5]'; read-only.
|
|
350
|
+
|
|
351
|
+
Raises a ValueError on a model of several networks, whose Layers are read from
|
|
352
|
+
'model.network[k].layer'; a ValueError, as nn.Module hides an AttributeError raised here.
|
|
353
|
+
"""
|
|
354
|
+
if len(self._network) != 1:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"'model.layer' reads a Model of one network; {type(self).__name__} has {len(self._network)}:"
|
|
357
|
+
" read 'model.network[k].layer[i]'"
|
|
358
|
+
)
|
|
359
|
+
return self._network[0].layer
|
|
360
|
+
|
|
361
|
+
def weights(self):
|
|
362
|
+
"""Returns a copy of the model's learned parameters, as 'load_weights()' takes them."""
|
|
363
|
+
raise NotImplementedError('weights method should be implemented by subclass')
|
|
364
|
+
|
|
365
|
+
def load_weights(self, weights):
|
|
366
|
+
"""Sets the model's learned parameters from 'weights()'."""
|
|
367
|
+
raise NotImplementedError(
|
|
368
|
+
'load_weights method should be implemented by subclass'
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def classify(self, X):
|
|
372
|
+
"""Returns a classifier's (columns, probabilities) for every row of X."""
|
|
373
|
+
raise NotImplementedError('classify method should be implemented by subclass')
|
|
374
|
+
|
|
375
|
+
def regress(self, X):
|
|
376
|
+
"""Returns a regressor's predicted values for every row of X."""
|
|
377
|
+
raise NotImplementedError('regress method should be implemented by subclass')
|
|
378
|
+
|
|
379
|
+
def train_step(self, inputs, labels, optimizer, name):
|
|
380
|
+
"""
|
|
381
|
+
Trains on one batch: computes its losses, steps its optimizer(s), and returns its metrics.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
inputs: the batch's samples, as 'data_loaders()' serves them, on the CPU.
|
|
385
|
+
labels: the batch's labels, as 'data_loaders()' serves them, on the CPU.
|
|
386
|
+
optimizer: what 'train_model()' was given: one optimizer, or a dict keyed by
|
|
387
|
+
'optimizer_keys'.
|
|
388
|
+
name: the phase as a message names it, e.g. "'model.data' phase 'train'".
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
{metric: total over the batch's samples}, one per metric of its 'MODEL_KINDS'
|
|
392
|
+
entry, in order: a loss is its batch mean times the batch's size, "acc" a count, so
|
|
393
|
+
the trainer's average over the phase weighs every sample alike.
|
|
394
|
+
"""
|
|
395
|
+
raise NotImplementedError('train_step method should be implemented by subclass')
|
|
396
|
+
|
|
397
|
+
def score_step(self, inputs, labels, name):
|
|
398
|
+
"""
|
|
399
|
+
Scores one batch without training, returning its metrics as 'train_step()' does.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
inputs: the batch's samples, as 'train_step()' takes them.
|
|
403
|
+
labels: the batch's labels, as 'train_step()' takes them.
|
|
404
|
+
name: the phase as a message names it, e.g. "'model.data' phase 'val'".
|
|
405
|
+
"""
|
|
406
|
+
raise NotImplementedError('score_step method should be implemented by subclass')
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class NeuronModel(Model):
|
|
410
|
+
"""
|
|
411
|
+
Abstract single neuron, the parent of our regression and classification models.
|
|
412
|
+
|
|
413
|
+
Subclasses set 'activation_name' (a key of 'NEURON_KINDS', which applies it and its slope),
|
|
414
|
+
'kind', and 'label_value', and implement 'loss()' and 'loss_gradient()'; a classifier also
|
|
415
|
+
implements 'probability()'.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
in_features: the number of input features, the length of the weight vector 'w'.
|
|
419
|
+
seed: seeds the starting weights, a whole number from 0 to 2**32 - 1, so every build with
|
|
420
|
+
it starts the same, without changing NumPy's global random stream; None for
|
|
421
|
+
unseeded weights, e.g. after the Notebook's own 'np.random.seed()'.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
# a single neuron is one layer, with no hidden layer before it
|
|
425
|
+
hidden_size = ()
|
|
426
|
+
# how its networks are found and read, a key of 'NETWORK_KINDS'
|
|
427
|
+
network_kind = 'numpy'
|
|
428
|
+
# each network's name, in the order they run, and what it reads: 'data' or an earlier network
|
|
429
|
+
sources: ClassVar[dict] = {'neuron': ('data',)}
|
|
430
|
+
|
|
431
|
+
def __init__(self, in_features, seed=None):
|
|
432
|
+
super().__init__({'in_features': in_features, 'seed': seed})
|
|
433
|
+
# self.w = np.zeros(in_features)
|
|
434
|
+
# self.w_0 = 0.
|
|
435
|
+
# Better, we set initial weights to small normally distributed values.
|
|
436
|
+
with _seeded(seed):
|
|
437
|
+
self.w = 0.01 * np.random.randn(in_features)
|
|
438
|
+
self.w_0 = 0.01 * np.random.randn()
|
|
439
|
+
# add this to divisions to ensure we don't divide by 0
|
|
440
|
+
self.non_zero_tolerance = NON_ZERO
|
|
441
|
+
self._network = NETWORK_KINDS[self.network_kind]['networks'](self)
|
|
442
|
+
|
|
443
|
+
def forward(self, x):
|
|
444
|
+
"""Returns the activation a for one sample x, caching its 'z' and 'a' for 'gradient()'."""
|
|
445
|
+
# Calculate and save the pre-activation z
|
|
446
|
+
self.z = x @ self.w.T + self.w_0
|
|
447
|
+
|
|
448
|
+
# Apply the activation function, and return
|
|
449
|
+
self.a = self.activation(self.z)
|
|
450
|
+
return self.a
|
|
451
|
+
|
|
452
|
+
def activation(self, z):
|
|
453
|
+
"""Returns the activation a of the pre-activation z, as its 'activation_name' row of 'NEURON_KINDS' applies it."""
|
|
454
|
+
return NEURON_KINDS[self.activation_name]['activation'](z)
|
|
455
|
+
|
|
456
|
+
# calculate and save gradient of our output with respect to weights
|
|
457
|
+
def gradient(self, x):
|
|
458
|
+
"""
|
|
459
|
+
Caches 'grad_w' and 'grad_w_0', da/dw = slope(a) x and da/dw_0 = slope(a), from 'forward()'s cached 'a'.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
x: the sample 'forward()' scored, (in_features,), or its batch, (N, in_features),
|
|
463
|
+
whose 'grad_w' is one row per sample.
|
|
464
|
+
"""
|
|
465
|
+
slope = NEURON_KINDS[self.activation_name]['slope'](self.a)
|
|
466
|
+
self.grad_w = np.asarray(slope)[..., None] * x
|
|
467
|
+
self.grad_w_0 = slope
|
|
468
|
+
|
|
469
|
+
# update weights based on gradients and learning rate
|
|
470
|
+
def update(self, grad_loss, learning_rate):
|
|
471
|
+
"""
|
|
472
|
+
Steps 'w' and 'w_0' against dL/da times the cached 'grad_w' and 'grad_w_0', scaled by learning_rate.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
grad_loss: dL/da of the sample 'gradient()' cached, or of each sample of its batch, (N,).
|
|
476
|
+
learning_rate: the step size.
|
|
477
|
+
|
|
478
|
+
Note:
|
|
479
|
+
A batch steps by the mean of its samples' updates; the mean of one sample is that
|
|
480
|
+
sample's update exactly, so a batch of one is the course's per-sample loop.
|
|
481
|
+
"""
|
|
482
|
+
grad_loss = np.reshape(grad_loss, (-1, 1))
|
|
483
|
+
grad_w = np.reshape(self.grad_w, (len(grad_loss), -1))
|
|
484
|
+
self.w -= np.mean(grad_loss * grad_w, axis=0) * learning_rate
|
|
485
|
+
self.w_0 -= np.mean(grad_loss[:, 0] * self.grad_w_0) * learning_rate
|
|
486
|
+
|
|
487
|
+
def train_step(self, inputs, labels, optimizer, name):
|
|
488
|
+
"""
|
|
489
|
+
Trains on one batch, as 'Model.train_step()': forward, gradient, then 'optimizer.step()'.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
inputs: the batch's samples, (N, in_features), as 'data_loaders()' serves them.
|
|
493
|
+
labels: the batch's labels, (N,), each one of its 'label_value'.
|
|
494
|
+
optimizer: a NeuronOptimizer, which holds the learning rate.
|
|
495
|
+
name: the phase as a message names it, e.g. "'model.data' phase 'train'".
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
{"loss": total} and, for a classifier, "acc": the count it gets right, each scored
|
|
499
|
+
before the batch's update, as the course's loop scores each sample.
|
|
500
|
+
"""
|
|
501
|
+
if not isinstance(optimizer, NeuronOptimizer):
|
|
502
|
+
raise TypeError(
|
|
503
|
+
f"'optimizer' must be a NeuronOptimizer for a {type(self).__name__},"
|
|
504
|
+
f" e.g. 'NeuronOptimizer(lr=0.01)', got {type(optimizer).__name__}"
|
|
505
|
+
)
|
|
506
|
+
X, Y = self._batch(inputs, labels, name)
|
|
507
|
+
a = self.forward(X)
|
|
508
|
+
totals = self._totals(a, Y)
|
|
509
|
+
self.gradient(X)
|
|
510
|
+
optimizer.step(self, self.loss_gradient(a, Y))
|
|
511
|
+
return totals
|
|
512
|
+
|
|
513
|
+
def score_step(self, inputs, labels, name):
|
|
514
|
+
"""Scores one batch without training, as 'Model.score_step()', leaving the cached 'z' and 'a' as they were."""
|
|
515
|
+
X, Y = self._batch(inputs, labels, name)
|
|
516
|
+
return self._totals(self.outputs(X), Y)
|
|
517
|
+
|
|
518
|
+
def _batch(self, inputs, labels, name):
|
|
519
|
+
"""
|
|
520
|
+
Returns a batch as NumPy (X, Y), raising a ValueError naming the phase unless each sample has one known label.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
inputs: the batch's samples, a tensor.
|
|
524
|
+
labels: the batch's labels, a tensor.
|
|
525
|
+
name: the phase as a message names it.
|
|
526
|
+
"""
|
|
527
|
+
X, Y = inputs.numpy(), labels.numpy()
|
|
528
|
+
# a single neuron has one output, so one label per sample
|
|
529
|
+
if Y.ndim != 1:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f'{name} has labels of shape {Y.shape}; a single neuron needs one label per sample'
|
|
532
|
+
)
|
|
533
|
+
if self.label_value is not None:
|
|
534
|
+
unknown = sorted(set(np.unique(Y).tolist()) - set(self.label_value))
|
|
535
|
+
if unknown:
|
|
536
|
+
raise ValueError(
|
|
537
|
+
f'{name} holds labels {unknown};'
|
|
538
|
+
f' a {type(self).__name__} takes {list(self.label_value)}'
|
|
539
|
+
)
|
|
540
|
+
return X, Y
|
|
541
|
+
|
|
542
|
+
def _totals(self, a, Y):
|
|
543
|
+
"""Returns the batch's {"loss": total} and, for a classifier, its "acc" count, from its activations a."""
|
|
544
|
+
totals = {'loss': float(self.loss(a, Y).sum())}
|
|
545
|
+
if 'acc' in MODEL_KINDS[self.kind]['metrics']:
|
|
546
|
+
# the prediction's column is the position of its label in 'label_value'
|
|
547
|
+
predicted = np.asarray(self.label_value)[(a > self.threshold).astype(int)]
|
|
548
|
+
totals['acc'] = float((predicted == Y).sum())
|
|
549
|
+
return totals
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def threshold(self):
|
|
553
|
+
"""The activation at the decision boundary z = 0; a classifier predicts its higher class above it."""
|
|
554
|
+
return self.activation(0.0)
|
|
555
|
+
|
|
556
|
+
def outputs(self, X):
|
|
557
|
+
"""
|
|
558
|
+
Returns the activation a for every row of X, leaving 'z', 'a', 'grad_w', and 'grad_w_0' as they were.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
X: the input samples, a NumPy array of shape (N, in_features).
|
|
562
|
+
"""
|
|
563
|
+
return self.activation(X @ self.w + self.w_0)
|
|
564
|
+
|
|
565
|
+
def classify(self, X):
|
|
566
|
+
"""
|
|
567
|
+
Returns a classifier's predicted column and class probabilities for every row of X.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
X: the input samples, a NumPy array of shape (N, in_features).
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
(columns, probabilities): (N,) int64 columns, 1 where a is above 'threshold', else 0;
|
|
574
|
+
(N, 2) probabilities, the lower class first, as 'predict_classes()' returns them.
|
|
575
|
+
"""
|
|
576
|
+
a = self.outputs(X)
|
|
577
|
+
p = self.probability(a)
|
|
578
|
+
columns = (a > self.threshold).astype(np.int64)
|
|
579
|
+
return columns, np.column_stack([1 - p, p])
|
|
580
|
+
|
|
581
|
+
def regress(self, X):
|
|
582
|
+
"""Returns a regressor's (N,) predicted values for every row of X, its activation a."""
|
|
583
|
+
return self.outputs(X)
|
|
584
|
+
|
|
585
|
+
def weights(self):
|
|
586
|
+
"""Returns a copy of 'w' and 'w_0', 'w' as a tensor, as 'model_save()' stores it."""
|
|
587
|
+
return {'w': torch.tensor(self.w), 'w_0': float(self.w_0)}
|
|
588
|
+
|
|
589
|
+
def load_weights(self, weights):
|
|
590
|
+
"""Sets 'w' and 'w_0' from 'weights()'."""
|
|
591
|
+
self.w = weights['w'].numpy().copy()
|
|
592
|
+
self.w_0 = weights['w_0']
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class RegressorNeuronModel(NeuronModel):
|
|
596
|
+
"""Single neuron regressor, a linear activation, for any real target."""
|
|
597
|
+
|
|
598
|
+
activation_name = 'linear'
|
|
599
|
+
kind = 'regression'
|
|
600
|
+
# any real target
|
|
601
|
+
label_value = None
|
|
602
|
+
|
|
603
|
+
def loss(self, a, y):
|
|
604
|
+
"""Squared error, as nn.MSELoss, so a neuron and a network report one loss."""
|
|
605
|
+
return (a - y) ** 2
|
|
606
|
+
|
|
607
|
+
def loss_gradient(self, a, y):
|
|
608
|
+
"""dL/da of the squared error."""
|
|
609
|
+
return 2 * (a - y)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class LogisticClassifierNeuronModel(NeuronModel):
|
|
613
|
+
"""Single neuron logistic classifier, a sigmoid activation, for 0 / 1 labels."""
|
|
614
|
+
|
|
615
|
+
activation_name = 'sigmoid'
|
|
616
|
+
kind = 'classification'
|
|
617
|
+
label_value = (0, 1)
|
|
618
|
+
|
|
619
|
+
def probability(self, a):
|
|
620
|
+
"""The probability of label 1: the sigmoid's a itself."""
|
|
621
|
+
return a
|
|
622
|
+
|
|
623
|
+
def loss(self, a, y):
|
|
624
|
+
"""Binary cross entropy, the negative log likelihood of the 0 / 1 label y."""
|
|
625
|
+
tolerance = self.non_zero_tolerance
|
|
626
|
+
return -(y * np.log(a + tolerance) + (1 - y) * np.log(1 - a + tolerance))
|
|
627
|
+
|
|
628
|
+
def loss_gradient(self, a, y):
|
|
629
|
+
"""dL/da of the binary cross entropy."""
|
|
630
|
+
return (a - y) / (a * (1 - a) + self.non_zero_tolerance)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class BipolarClassifierNeuronModel(NeuronModel):
|
|
634
|
+
"""
|
|
635
|
+
Single neuron bipolar classifier, a tanh activation.
|
|
636
|
+
|
|
637
|
+
Outputs are in (-1, 1), so it predicts class 1 when a > 0 and expects -1 / +1 labels.
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
activation_name = 'tanh'
|
|
641
|
+
kind = 'classification'
|
|
642
|
+
label_value = (-1, 1)
|
|
643
|
+
|
|
644
|
+
def probability(self, a):
|
|
645
|
+
"""The probability of label +1: a rescaled from (-1, 1) to (0, 1)."""
|
|
646
|
+
return (a + 1) / 2
|
|
647
|
+
|
|
648
|
+
def loss(self, a, y):
|
|
649
|
+
"""Binary cross entropy of the probability (a + 1) / 2 against the -1 / +1 label y as 0 / 1."""
|
|
650
|
+
p, t = (a + 1) / 2, (y + 1) / 2
|
|
651
|
+
tolerance = self.non_zero_tolerance
|
|
652
|
+
return -(t * np.log(p + tolerance) + (1 - t) * np.log(1 - p + tolerance))
|
|
653
|
+
|
|
654
|
+
def loss_gradient(self, a, y):
|
|
655
|
+
"""dL/da of that cross entropy, (a - y) / (1 - a^2); times 'grad_w' it is (a - y) x."""
|
|
656
|
+
return (a - y) / (1 - a**2 + self.non_zero_tolerance)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class _CriterionModel(Model, nn.Module):
|
|
660
|
+
"""
|
|
661
|
+
What every PyTorch Model trained on one 'criterion' and one optimizer shares: its step.
|
|
662
|
+
|
|
663
|
+
Note:
|
|
664
|
+
A subclass sets 'criterion', the loss class, and overrides '_targets()' when its loss
|
|
665
|
+
reads other targets than its labels, and '_fitted()' when its outputs must be shaped
|
|
666
|
+
to them.
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
def train_step(self, inputs, labels, optimizer, name):
|
|
670
|
+
"""
|
|
671
|
+
Trains on one batch, as 'Model.train_step()': forward, loss, backward, and 'optimizer.step()'.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
inputs: the batch's samples, as 'data_loaders()' serves them.
|
|
675
|
+
labels: the batch's labels, as 'data_loaders()' serves them.
|
|
676
|
+
optimizer: a PyTorch optimizer of the model's parameters, e.g. torch.optim.Adam.
|
|
677
|
+
name: the phase as a message names it, e.g. "'model.data' phase 'train'".
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
{"loss": the batch mean times its size} and, for a classifier, "acc": the share of
|
|
681
|
+
each sample's answers it gets right, summed over the batch.
|
|
682
|
+
"""
|
|
683
|
+
if not isinstance(optimizer, torch.optim.Optimizer):
|
|
684
|
+
raise TypeError(
|
|
685
|
+
f"'optimizer' must be a PyTorch optimizer for a {type(self).__name__},"
|
|
686
|
+
f" e.g. 'torch.optim.Adam(model.parameters())', got {type(optimizer).__name__}"
|
|
687
|
+
)
|
|
688
|
+
self.train()
|
|
689
|
+
optimizer.zero_grad()
|
|
690
|
+
outputs, targets, loss = self._step(inputs, labels, name)
|
|
691
|
+
loss.backward()
|
|
692
|
+
optimizer.step()
|
|
693
|
+
return self._totals(outputs, targets, loss)
|
|
694
|
+
|
|
695
|
+
def score_step(self, inputs, labels, name):
|
|
696
|
+
"""Scores one batch in eval() without gradients, as 'Model.score_step()'."""
|
|
697
|
+
self.eval()
|
|
698
|
+
with torch.no_grad():
|
|
699
|
+
outputs, targets, loss = self._step(inputs, labels, name)
|
|
700
|
+
return self._totals(outputs, targets, loss)
|
|
701
|
+
|
|
702
|
+
def _step(self, inputs, labels, name):
|
|
703
|
+
"""
|
|
704
|
+
Returns one batch's (outputs, targets, loss), the batch moved to the model's device.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
inputs: the batch's samples.
|
|
708
|
+
labels: the batch's labels.
|
|
709
|
+
name: the phase as a message names it.
|
|
710
|
+
"""
|
|
711
|
+
device = next(self.parameters()).device
|
|
712
|
+
# every batch shaped as the network reads it: rows for a Linear network, images for conv stages
|
|
713
|
+
inputs = _shaped(inputs, self.in_shape, name).to(device)
|
|
714
|
+
targets = self._targets(inputs, labels.to(device), name)
|
|
715
|
+
outputs = self._fitted(self(inputs), targets)
|
|
716
|
+
return outputs, targets, self.criterion()(outputs, targets)
|
|
717
|
+
|
|
718
|
+
def _targets(self, inputs, labels, name):
|
|
719
|
+
"""Returns what its loss compares its outputs to: its labels."""
|
|
720
|
+
return labels
|
|
721
|
+
|
|
722
|
+
def _fitted(self, outputs, targets):
|
|
723
|
+
"""Returns its outputs shaped as its loss takes them: as they are."""
|
|
724
|
+
return outputs
|
|
725
|
+
|
|
726
|
+
def _totals(self, outputs, targets, loss):
|
|
727
|
+
"""Returns the batch's {"loss": total} and, for a classifier, its "acc" total."""
|
|
728
|
+
# each batch's mean times its size, so the phase average weighs every sample alike
|
|
729
|
+
totals = {'loss': loss.item() * len(targets)}
|
|
730
|
+
if 'acc' in MODEL_KINDS[self.kind]['metrics']:
|
|
731
|
+
matches = self.predicted(outputs) == targets
|
|
732
|
+
totals['acc'] = matches.sum().item() / targets[0].numel()
|
|
733
|
+
return totals
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class FeedforwardModel(_CriterionModel):
|
|
737
|
+
"""
|
|
738
|
+
Multi-layer feedforward network, the parent of our classification and regression networks.
|
|
739
|
+
|
|
740
|
+
Hidden 'Linear' layers, each followed by a ReLU (and a Dropout when 'dropout' > 0),
|
|
741
|
+
then a 'Linear' output layer, all in one 'nn.Sequential', its one Network, named for its
|
|
742
|
+
purpose in the subclass's 'sources', e.g. 'model.classifier' or 'model.regressor'.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
in_features: the number of input features, the width of the first layer's input.
|
|
746
|
+
hidden_size: the width of each hidden layer, in order, e.g. (64, 32); its length is
|
|
747
|
+
the number of hidden layers. () builds a single 'Linear' layer.
|
|
748
|
+
out_features: the width of the output layer.
|
|
749
|
+
dropout: the probability of zeroing each hidden activation while training, at least 0
|
|
750
|
+
and below 1; 0 adds no Dropout layers.
|
|
751
|
+
seed: seeds the starting weights, a whole number from 0 to 2**32 - 1, so every build with
|
|
752
|
+
it starts the same, without changing torch's global random stream; None for
|
|
753
|
+
unseeded weights, e.g. after the Notebook's own 'torch.manual_seed()'.
|
|
754
|
+
in_shape: one image's (channels, height, width), e.g. (1, 28, 28), whose product is
|
|
755
|
+
'in_features'; given with 'convolution' and only with it.
|
|
756
|
+
convolution: the convolution stages before the 'Linear' layers, in order, each a dict of
|
|
757
|
+
'CONV_STAGE_KEYS', e.g. ({"channels": 8, "kernel": 3, "pool": 2},): a
|
|
758
|
+
Conv2d that keeps the height and width, a ReLU, then a MaxPool2d (none for a
|
|
759
|
+
pool of 1). The last stage's maps are flattened into the first 'Linear'
|
|
760
|
+
layer. () builds 'Linear' layers only.
|
|
761
|
+
|
|
762
|
+
Note:
|
|
763
|
+
The course's 'SimpleClassifier2Layer' is hidden_size=(h1, h2): two hidden layers,
|
|
764
|
+
three 'Linear' layers. Every layer is in the course's order, so a course model's
|
|
765
|
+
'state_dict()' loads into the matching model here by order, its 'layers.N' keys read
|
|
766
|
+
as 'classifier.N'. Dropout acts only in 'model.train()' mode; 'model.eval()' turns it off,
|
|
767
|
+
and it follows only the hidden 'Linear' layers. The course's Module 4.1
|
|
768
|
+
'CNNClassifier' is (784, (64,), 10, in_shape=(1, 28, 28),
|
|
769
|
+
convolution=({"channels": 8, "kernel": 3, "pool": 2},)).
|
|
770
|
+
|
|
771
|
+
Example:
|
|
772
|
+
>>> model = ClassificationModel(input_size, (64, 64, 64), num_classes, dropout=0.25).to(device)
|
|
773
|
+
>>> model = ClassificationModel(28 * 28, (64,), 10, in_shape=(1, 28, 28),
|
|
774
|
+
... convolution=({"channels": 8, "kernel": 3, "pool": 2},)).to(device)
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
activation_name = 'relu'
|
|
778
|
+
# how its networks are found and read, a key of 'NETWORK_KINDS'
|
|
779
|
+
network_kind = 'torch'
|
|
780
|
+
# each network's name, in the order they run, and what it reads: 'data' or an earlier network
|
|
781
|
+
sources: ClassVar[dict]
|
|
782
|
+
|
|
783
|
+
def __init__(
|
|
784
|
+
self,
|
|
785
|
+
in_features,
|
|
786
|
+
hidden_size,
|
|
787
|
+
out_features,
|
|
788
|
+
dropout=0.0,
|
|
789
|
+
seed=None,
|
|
790
|
+
in_shape=None,
|
|
791
|
+
convolution=(),
|
|
792
|
+
):
|
|
793
|
+
# V A L I D A T E: every width and the dropout rate fail by name before any layer is built
|
|
794
|
+
_check_width('in_features', in_features)
|
|
795
|
+
_check_sizes('hidden_size', hidden_size)
|
|
796
|
+
_check_width('out_features', out_features)
|
|
797
|
+
_check_dropout(dropout)
|
|
798
|
+
_check_conv(in_features, in_shape, convolution, is_mirrored=False)
|
|
799
|
+
|
|
800
|
+
# A R G U M E N T S: kept as plain numbers, so model_load() reads them back with weights_only
|
|
801
|
+
conv = _conv_arguments(in_shape, convolution)
|
|
802
|
+
super().__init__(
|
|
803
|
+
{
|
|
804
|
+
'in_features': int(in_features),
|
|
805
|
+
**conv,
|
|
806
|
+
'hidden_size': tuple(int(width) for width in hidden_size),
|
|
807
|
+
'out_features': int(out_features),
|
|
808
|
+
'dropout': float(dropout),
|
|
809
|
+
'seed': seed,
|
|
810
|
+
}
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# S H A P E S: kept as built, since a file saved by a pre-release build records neither conv argument
|
|
814
|
+
self._in_shape = _sample_shape(in_features, conv['in_shape'])
|
|
815
|
+
self._stage_shapes = _stage_shapes(conv['in_shape'], conv['convolution'])
|
|
816
|
+
flat = math.prod([self._in_shape, *self._stage_shapes][-1])
|
|
817
|
+
|
|
818
|
+
# L A Y E R S: the conv stages, then Linear, ReLU, and (when asked) Dropout per hidden layer, then the output
|
|
819
|
+
(name,) = self.sources
|
|
820
|
+
with _seeded(seed):
|
|
821
|
+
setattr(
|
|
822
|
+
self,
|
|
823
|
+
name,
|
|
824
|
+
nn.Sequential(
|
|
825
|
+
*_conv_stack(conv['in_shape'], conv['convolution']),
|
|
826
|
+
*_stack((flat, *hidden_size, out_features), dropout),
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
self._network = NETWORK_KINDS[self.network_kind]['networks'](self)
|
|
830
|
+
|
|
831
|
+
def forward(self, x):
|
|
832
|
+
"""Returns the network's raw outputs for the batch x, as nn.Module calls it."""
|
|
833
|
+
(name,) = self.sources
|
|
834
|
+
return getattr(self, name)(x)
|
|
835
|
+
|
|
836
|
+
@property
|
|
837
|
+
def hidden_size(self):
|
|
838
|
+
"""
|
|
839
|
+
The output of each hidden layer, in order, as the network was built: each conv stage's
|
|
840
|
+
(channels, height, width), then each hidden 'Linear' layer's width, e.g. ((8, 14, 14), 64).
|
|
841
|
+
"""
|
|
842
|
+
return (*self._stage_shapes, *self.arguments['hidden_size'])
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def in_shape(self):
|
|
846
|
+
"""One sample's shape as the network reads it: (channels, height, width), or (in_features,) with no conv stages."""
|
|
847
|
+
return self._in_shape
|
|
848
|
+
|
|
849
|
+
def outputs(self, X):
|
|
850
|
+
"""
|
|
851
|
+
Returns the network's outputs for every sample, scored in eval() without gradients, its mode left as it was.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
X: the input samples, one per row: a tensor or NumPy array of shape (N, ...), each
|
|
855
|
+
sample 'in_features' values; each is shaped to 'in_shape', as 'train_model()'
|
|
856
|
+
feeds the network, so images and flattened rows score the same.
|
|
857
|
+
|
|
858
|
+
Returns:
|
|
859
|
+
the (N, out_features) outputs tensor, on the CPU.
|
|
860
|
+
|
|
861
|
+
Note:
|
|
862
|
+
X is scored in batches of the latest training's 'batch_size' (64 before any training),
|
|
863
|
+
each moved to the model's device alone, so a phase of any size fits the GPU.
|
|
864
|
+
"""
|
|
865
|
+
return _scored(self, self, X, 'X', self.in_shape)
|
|
866
|
+
|
|
867
|
+
def classify(self, X):
|
|
868
|
+
"""
|
|
869
|
+
Returns a classifier's predicted column(s) and probabilities for every sample.
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
X: the input samples, as 'outputs()' takes them.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
(columns, probabilities) as NumPy arrays, columns int64 and probabilities float64:
|
|
876
|
+
(N,) columns and (N, k) probabilities for one label per sample; (N, k) of each for
|
|
877
|
+
a binary network's k independent labels.
|
|
878
|
+
"""
|
|
879
|
+
logits = self.outputs(X)
|
|
880
|
+
columns = self.predicted(logits)
|
|
881
|
+
# a one-output model is one two-class label, as the views read it, not one label of many
|
|
882
|
+
if columns.ndim == 2 and columns.shape[1] == 1:
|
|
883
|
+
columns = columns[:, 0]
|
|
884
|
+
probabilities = self.probabilities(logits)
|
|
885
|
+
return columns.long().cpu().numpy(), probabilities.cpu().double().numpy()
|
|
886
|
+
|
|
887
|
+
def regress(self, X):
|
|
888
|
+
"""
|
|
889
|
+
Returns a regressor's predicted value(s) for every sample.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
X: the input samples, as 'outputs()' takes them.
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
a float64 NumPy array: (N,) for one target, as the targets are, or (N, k) for k targets.
|
|
896
|
+
"""
|
|
897
|
+
return self.outputs(X).squeeze(1).cpu().double().numpy()
|
|
898
|
+
|
|
899
|
+
def weights(self):
|
|
900
|
+
"""Returns a copy of 'state_dict()' in system RAM, so later training cannot change it and it takes no GPU memory."""
|
|
901
|
+
return {
|
|
902
|
+
key: value.detach().to('cpu', copy=True)
|
|
903
|
+
for key, value in self.state_dict().items()
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
def load_weights(self, weights):
|
|
907
|
+
"""Loads 'weights()' into the network."""
|
|
908
|
+
self.load_state_dict(weights)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
class ClassificationModel(FeedforwardModel):
|
|
912
|
+
"""
|
|
913
|
+
Multi-layer classification network; 'out_features' is the number of classes.
|
|
914
|
+
|
|
915
|
+
Note:
|
|
916
|
+
Each output is a logit, one raw score per class: train with 'nn.CrossEntropyLoss',
|
|
917
|
+
which applies the softmax, and predict with 'outputs.argmax(1)'.
|
|
918
|
+
|
|
919
|
+
Example:
|
|
920
|
+
>>> model = ClassificationModel(input_size, (128, 64), num_classes).to(device)
|
|
921
|
+
"""
|
|
922
|
+
|
|
923
|
+
output_activation_name = 'softmax'
|
|
924
|
+
kind = 'classification'
|
|
925
|
+
# the loss class, built with PyTorch's default "mean" reduction the epoch loss assumes
|
|
926
|
+
criterion = nn.CrossEntropyLoss
|
|
927
|
+
sources: ClassVar[dict] = {'classifier': ('data',)}
|
|
928
|
+
|
|
929
|
+
def predicted(self, outputs):
|
|
930
|
+
"""Each sample's predicted column, the highest logit's."""
|
|
931
|
+
return outputs.argmax(1)
|
|
932
|
+
|
|
933
|
+
def probabilities(self, logits):
|
|
934
|
+
"""(N, k) class probabilities, each row summing to 1."""
|
|
935
|
+
return torch.softmax(logits, dim=1)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
class RegressionModel(FeedforwardModel):
|
|
939
|
+
"""
|
|
940
|
+
Multi-layer regression network; 'out_features' is the number of targets.
|
|
941
|
+
|
|
942
|
+
Note:
|
|
943
|
+
The output is (N, out_features), e.g. (N, 1) for one target; flatten it to match
|
|
944
|
+
(N,) targets before 'nn.MSELoss', as the course's training loop does.
|
|
945
|
+
|
|
946
|
+
Example:
|
|
947
|
+
>>> model = RegressionModel(input_size, (64, 64, 64), 1, dropout=0.25).to(device)
|
|
948
|
+
"""
|
|
949
|
+
|
|
950
|
+
output_activation_name = 'linear'
|
|
951
|
+
kind = 'regression'
|
|
952
|
+
criterion = nn.MSELoss
|
|
953
|
+
sources: ClassVar[dict] = {'regressor': ('data',)}
|
|
954
|
+
|
|
955
|
+
def _fitted(self, outputs, targets):
|
|
956
|
+
"""Returns (N, 1) outputs reshaped to (N,) targets, or nn.MSELoss broadcasts to (N, N)."""
|
|
957
|
+
return _flattened(outputs, targets)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
class BinaryClassificationModel(FeedforwardModel):
|
|
961
|
+
"""
|
|
962
|
+
Multi-layer binary classification network; 'out_features' is the number of yes / no labels.
|
|
963
|
+
|
|
964
|
+
Note:
|
|
965
|
+
Each output is a logit, one raw score per label: train with 'nn.BCEWithLogitsLoss',
|
|
966
|
+
which applies the sigmoid, and predict with 'outputs > 0'. out_features=1 is one
|
|
967
|
+
two-class label; out_features=k is k independent labels (multi-label), each its own
|
|
968
|
+
yes / no, where a softmax classifier's outputs would compete.
|
|
969
|
+
|
|
970
|
+
Example:
|
|
971
|
+
>>> model = BinaryClassificationModel(input_size, (64, 32), 1).to(device)
|
|
972
|
+
"""
|
|
973
|
+
|
|
974
|
+
output_activation_name = 'sigmoid'
|
|
975
|
+
kind = 'classification'
|
|
976
|
+
criterion = nn.BCEWithLogitsLoss
|
|
977
|
+
sources: ClassVar[dict] = {'classifier': ('data',)}
|
|
978
|
+
|
|
979
|
+
def _targets(self, inputs, labels, name):
|
|
980
|
+
"""Returns the 0 / 1 labels cast to the model's float dtype for BCE, raising a ValueError naming the phase for any other."""
|
|
981
|
+
if not ((labels == 0) | (labels == 1)).all():
|
|
982
|
+
raise ValueError(
|
|
983
|
+
f'{name} holds labels other than 0 and 1;'
|
|
984
|
+
' each output of the model is one yes / no'
|
|
985
|
+
)
|
|
986
|
+
return labels.to(next(self.parameters()).dtype)
|
|
987
|
+
|
|
988
|
+
def _fitted(self, outputs, targets):
|
|
989
|
+
"""Returns the outputs reshaped to the labels' shape, (N,) for one output."""
|
|
990
|
+
return _flattened(outputs, targets)
|
|
991
|
+
|
|
992
|
+
def predicted(self, outputs):
|
|
993
|
+
"""Each output's predicted yes / no, read from the logit, where a rounded probability could tie."""
|
|
994
|
+
return outputs > 0
|
|
995
|
+
|
|
996
|
+
def probabilities(self, logits):
|
|
997
|
+
"""
|
|
998
|
+
Each output's probability of yes; for one output, the two class probabilities, the lower
|
|
999
|
+
class first, where sigmoid(-z) is 1 - sigmoid(z) without cancellation.
|
|
1000
|
+
"""
|
|
1001
|
+
if logits.shape[1] == 1:
|
|
1002
|
+
return torch.cat([torch.sigmoid(-logits), torch.sigmoid(logits)], dim=1)
|
|
1003
|
+
return torch.sigmoid(logits)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
class AutoencoderModel(_CriterionModel):
|
|
1007
|
+
"""
|
|
1008
|
+
Multi-layer autoencoder: an encoder that compresses each sample to a small latent code, and a
|
|
1009
|
+
decoder that rebuilds the sample from it.
|
|
1010
|
+
|
|
1011
|
+
The encoder is hidden 'Linear' layers, each followed by a ReLU (and a Dropout when 'dropout'
|
|
1012
|
+
> 0), down to a 'Linear' latent layer with no activation; the decoder mirrors it back to
|
|
1013
|
+
'in_features', then applies its output style.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
in_features: the number of input features, e.g. 784 for a 28 x 28 image.
|
|
1017
|
+
encoder_size: the width of each hidden encoder layer, in order, e.g. (128, 64); the
|
|
1018
|
+
decoder's hidden layers are the same widths reversed. () builds a single
|
|
1019
|
+
'Linear' layer each way.
|
|
1020
|
+
latent_size: the width of the latent code, e.g. 2 to plot it as it is.
|
|
1021
|
+
output_activation: the decoder's output style, one of 'AUTOENCODER_OUTPUTS': "linear"
|
|
1022
|
+
(the course's) for standardized rows or Normalize()d images, "sigmoid"
|
|
1023
|
+
for inputs in [0, 1], "tanh" for inputs in [-1, 1].
|
|
1024
|
+
dropout: the probability of zeroing each hidden activation while training, at least 0
|
|
1025
|
+
and below 1; 0 adds no Dropout layers.
|
|
1026
|
+
seed: seeds the starting weights, a whole number from 0 to 2**32 - 1, so every build with
|
|
1027
|
+
it starts the same, without changing torch's global random stream; None for
|
|
1028
|
+
unseeded weights, e.g. after the Notebook's own 'torch.manual_seed()'.
|
|
1029
|
+
in_shape: one image's (channels, height, width), as 'FeedforwardModel' takes it.
|
|
1030
|
+
convolution: the encoder's convolution stages, as 'FeedforwardModel' takes them; each pool must
|
|
1031
|
+
divide the height and width it pools, so the decoder can rebuild them. The
|
|
1032
|
+
decoder mirrors them with one ConvTranspose2d per stage, its kernel and
|
|
1033
|
+
stride the stage's pool, a ReLU between each, back to 'in_shape'.
|
|
1034
|
+
|
|
1035
|
+
Note:
|
|
1036
|
+
Train with 'train_model()', on nn.MSELoss of each sample against itself; the labels of
|
|
1037
|
+
its data are never trained on, and 'classes', when given, color 'show_latent_space()'.
|
|
1038
|
+
The course's 'SimpleAutoEncoder' is (784, (128, 64), 2) and its 'AutoEncoder' is
|
|
1039
|
+
(784, (256,), 32): every layer is in the course's order under the names 'encoder' and
|
|
1040
|
+
'decoder', and no output style holds weights, so a course model's 'state_dict()' loads
|
|
1041
|
+
into the matching model here. The course's Module 4.1 'CNNAutoEncoder' is (784, (), 32,
|
|
1042
|
+
in_shape=(1, 28, 28), convolution=({"channels": 8, "kernel": 3, "pool": 2},)), less its
|
|
1043
|
+
two ReLUs that follow a ReLU and change nothing; its weights load in their order. The
|
|
1044
|
+
trainer and views read only 'encode()', 'decode()', 'reconstruct()', and the class
|
|
1045
|
+
attributes.
|
|
1046
|
+
|
|
1047
|
+
Example:
|
|
1048
|
+
>>> model = AutoencoderModel(28 * 28, (128, 64), 2).to(device)
|
|
1049
|
+
>>> model.data = data_dataset_split(mnist_train, mnist_test, 0.25, digit_classes, seed=0)
|
|
1050
|
+
>>> model = AutoencoderModel(28 * 28, (), 32, in_shape=(1, 28, 28),
|
|
1051
|
+
... convolution=({"channels": 8, "kernel": 3, "pool": 2},)).to(device)
|
|
1052
|
+
"""
|
|
1053
|
+
|
|
1054
|
+
activation_name = 'relu'
|
|
1055
|
+
kind = 'autoencoder'
|
|
1056
|
+
criterion = nn.MSELoss
|
|
1057
|
+
# how its networks are found and read, a key of 'NETWORK_KINDS'
|
|
1058
|
+
network_kind = 'torch'
|
|
1059
|
+
# each network's name, in the order they run, and what it reads: 'data' or an earlier network
|
|
1060
|
+
sources: ClassVar[dict] = {'encoder': ('data',), 'decoder': ('encoder',)}
|
|
1061
|
+
|
|
1062
|
+
def __init__(
|
|
1063
|
+
self,
|
|
1064
|
+
in_features,
|
|
1065
|
+
encoder_size,
|
|
1066
|
+
latent_size,
|
|
1067
|
+
output_activation='linear',
|
|
1068
|
+
dropout=0.0,
|
|
1069
|
+
seed=None,
|
|
1070
|
+
in_shape=None,
|
|
1071
|
+
convolution=(),
|
|
1072
|
+
):
|
|
1073
|
+
# V A L I D A T E: every width, the output style, and the dropout rate fail by name first
|
|
1074
|
+
_check_width('in_features', in_features)
|
|
1075
|
+
_check_sizes('encoder_size', encoder_size)
|
|
1076
|
+
_check_width('latent_size', latent_size)
|
|
1077
|
+
_check_output_activation(output_activation)
|
|
1078
|
+
_check_dropout(dropout)
|
|
1079
|
+
_check_conv(in_features, in_shape, convolution, is_mirrored=True)
|
|
1080
|
+
|
|
1081
|
+
# A R G U M E N T S: kept as plain values, so model_load() reads them back with weights_only
|
|
1082
|
+
conv = _conv_arguments(in_shape, convolution)
|
|
1083
|
+
super().__init__(
|
|
1084
|
+
{
|
|
1085
|
+
'in_features': int(in_features),
|
|
1086
|
+
**conv,
|
|
1087
|
+
'encoder_size': tuple(int(width) for width in encoder_size),
|
|
1088
|
+
'latent_size': int(latent_size),
|
|
1089
|
+
'output_activation': output_activation,
|
|
1090
|
+
'dropout': float(dropout),
|
|
1091
|
+
'seed': seed,
|
|
1092
|
+
}
|
|
1093
|
+
)
|
|
1094
|
+
self.output_activation_name = output_activation
|
|
1095
|
+
|
|
1096
|
+
# S H A P E S: kept as built, since a file saved by a pre-release build records neither conv argument
|
|
1097
|
+
self._in_shape = _sample_shape(in_features, conv['in_shape'])
|
|
1098
|
+
self._stage_shapes = _stage_shapes(conv['in_shape'], conv['convolution'])
|
|
1099
|
+
flat = math.prod([self._in_shape, *self._stage_shapes][-1])
|
|
1100
|
+
|
|
1101
|
+
# L A Y E R S: the encoder down to the latent code, then the decoder mirrored back up
|
|
1102
|
+
widths = (flat, *encoder_size, latent_size)
|
|
1103
|
+
with _seeded(seed):
|
|
1104
|
+
self.encoder = nn.Sequential(
|
|
1105
|
+
*_conv_stack(conv['in_shape'], conv['convolution']),
|
|
1106
|
+
*_stack(widths, dropout),
|
|
1107
|
+
)
|
|
1108
|
+
output = NEURON_KINDS[output_activation]['module']()
|
|
1109
|
+
self.decoder = nn.Sequential(
|
|
1110
|
+
*_stack(widths[::-1], dropout),
|
|
1111
|
+
*_deconv_stack(conv['in_shape'], conv['convolution']),
|
|
1112
|
+
output,
|
|
1113
|
+
)
|
|
1114
|
+
self._network = NETWORK_KINDS[self.network_kind]['networks'](self)
|
|
1115
|
+
|
|
1116
|
+
def forward(self, x):
|
|
1117
|
+
"""Returns the reconstruction of the batch x: encoded to its latent code, then decoded."""
|
|
1118
|
+
return self.decoder(self.encoder(x))
|
|
1119
|
+
|
|
1120
|
+
@property
|
|
1121
|
+
def hidden_size(self):
|
|
1122
|
+
"""
|
|
1123
|
+
The output of every layer between input and output: each encoder conv stage's (channels,
|
|
1124
|
+
height, width), the encoder widths, the latent size, then the decoder's mirror of both,
|
|
1125
|
+
e.g. (128, 64, 2, 64, 128), or ((8, 14, 14), 32, (8, 14, 14)).
|
|
1126
|
+
"""
|
|
1127
|
+
shapes = self._stage_shapes
|
|
1128
|
+
sizes = self.arguments['encoder_size']
|
|
1129
|
+
return (
|
|
1130
|
+
*shapes,
|
|
1131
|
+
*sizes,
|
|
1132
|
+
self.arguments['latent_size'],
|
|
1133
|
+
*sizes[::-1],
|
|
1134
|
+
*shapes[::-1],
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
@property
|
|
1138
|
+
def in_shape(self):
|
|
1139
|
+
"""One sample's shape as the network reads it: (channels, height, width), or (in_features,) with no conv stages."""
|
|
1140
|
+
return self._in_shape
|
|
1141
|
+
|
|
1142
|
+
@property
|
|
1143
|
+
def output_range(self):
|
|
1144
|
+
"""The (low, high) its output style can reach, e.g. (-1.0, 1.0) for "tanh"."""
|
|
1145
|
+
return NEURON_KINDS[self.output_activation_name]['range']
|
|
1146
|
+
|
|
1147
|
+
def _targets(self, inputs, labels, name):
|
|
1148
|
+
"""Returns the inputs, shaped as the network reads them, raising a ValueError naming the phase for any past 'output_range'."""
|
|
1149
|
+
low, high = self.output_range
|
|
1150
|
+
if inputs.min() < low or inputs.max() > high:
|
|
1151
|
+
raise ValueError(
|
|
1152
|
+
f'{name} holds inputs from {inputs.min().item():.4g} to'
|
|
1153
|
+
f' {inputs.max().item():.4g}; a {self.output_activation_name!r} output reconstructs'
|
|
1154
|
+
f' only [{low}, {high}]'
|
|
1155
|
+
)
|
|
1156
|
+
return inputs
|
|
1157
|
+
|
|
1158
|
+
def encode(self, X):
|
|
1159
|
+
"""
|
|
1160
|
+
Returns every sample's latent code, scored in eval() without gradients, its mode left as it was.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
X: the input samples, one per row: a tensor or NumPy array of shape (N, ...), each
|
|
1164
|
+
sample 'in_features' values; each is shaped to 'in_shape', as 'train_model()'
|
|
1165
|
+
feeds the network, so images and flattened rows score the same.
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
a float64 NumPy array of shape (N, latent_size).
|
|
1169
|
+
"""
|
|
1170
|
+
return self.network[0].outputs(X)
|
|
1171
|
+
|
|
1172
|
+
def decode(self, Z):
|
|
1173
|
+
"""
|
|
1174
|
+
Returns the sample the decoder rebuilds from every latent code, scored as 'encode()' scores.
|
|
1175
|
+
|
|
1176
|
+
Args:
|
|
1177
|
+
Z: the latent codes, a tensor or NumPy array of shape (N, latent_size).
|
|
1178
|
+
|
|
1179
|
+
Returns:
|
|
1180
|
+
a float64 NumPy array of shape (N, in_features), each image flattened to one row.
|
|
1181
|
+
"""
|
|
1182
|
+
outputs = self.network[1]._outputs(Z, 'Z')
|
|
1183
|
+
return outputs.reshape(len(outputs), -1)
|
|
1184
|
+
|
|
1185
|
+
def reconstruct(self, X):
|
|
1186
|
+
"""
|
|
1187
|
+
Returns every sample encoded and decoded again, scored as 'encode()' scores.
|
|
1188
|
+
|
|
1189
|
+
Args:
|
|
1190
|
+
X: the input samples, as 'encode()' takes them.
|
|
1191
|
+
|
|
1192
|
+
Returns:
|
|
1193
|
+
a float64 NumPy array of shape (N, in_features), each row one sample's reconstruction.
|
|
1194
|
+
"""
|
|
1195
|
+
return (
|
|
1196
|
+
_scored(self, self, X, 'X', self.in_shape).flatten(1).cpu().double().numpy()
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
def weights(self):
|
|
1200
|
+
"""Returns a copy of 'state_dict()' in system RAM, so later training cannot change it and it takes no GPU memory."""
|
|
1201
|
+
return {
|
|
1202
|
+
key: value.detach().to('cpu', copy=True)
|
|
1203
|
+
for key, value in self.state_dict().items()
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
def load_weights(self, weights):
|
|
1207
|
+
"""Loads 'weights()' into the network."""
|
|
1208
|
+
self.load_state_dict(weights)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
class _ExternalModel:
|
|
1212
|
+
"""
|
|
1213
|
+
What every wrapped network shares: its networks given, not built, and its shapes read from its Layers.
|
|
1214
|
+
|
|
1215
|
+
Note:
|
|
1216
|
+
A wrapper lists it before its house class, e.g. 'ClassificationModel', so 'in_features',
|
|
1217
|
+
'hidden_size', and 'activation_name' are read from 'in_shape' and the probed Layers, not
|
|
1218
|
+
from the arguments the house class builds its networks from.
|
|
1219
|
+
"""
|
|
1220
|
+
|
|
1221
|
+
is_external = True
|
|
1222
|
+
|
|
1223
|
+
def _wrap(self, networks, in_shape, arguments):
|
|
1224
|
+
"""
|
|
1225
|
+
Checks the networks and 'in_shape', records the "create" event, and finds the Model's Networks.
|
|
1226
|
+
|
|
1227
|
+
Args:
|
|
1228
|
+
networks: each network by its name in 'sources', e.g. {"classifier": vgg11}.
|
|
1229
|
+
in_shape: one sample's shape as the networks read it, e.g. (3, 224, 224).
|
|
1230
|
+
arguments: its plain arguments after 'in_shape', e.g. {"output_activation": "tanh"}.
|
|
1231
|
+
"""
|
|
1232
|
+
if (
|
|
1233
|
+
not isinstance(in_shape, tuple)
|
|
1234
|
+
or not in_shape
|
|
1235
|
+
or any(
|
|
1236
|
+
isinstance(size, bool)
|
|
1237
|
+
or not isinstance(size, numbers.Integral)
|
|
1238
|
+
or size < 1
|
|
1239
|
+
for size in in_shape
|
|
1240
|
+
)
|
|
1241
|
+
):
|
|
1242
|
+
raise ValueError(
|
|
1243
|
+
f"'in_shape' must be a tuple of whole numbers of at least 1, e.g. (3, 224, 224), got {in_shape!r}"
|
|
1244
|
+
)
|
|
1245
|
+
for name, network in networks.items():
|
|
1246
|
+
if not isinstance(network, nn.Module):
|
|
1247
|
+
raise TypeError(
|
|
1248
|
+
f"'{name}' must be a PyTorch network, e.g. torchvision's VGG11, got {type(network).__name__}"
|
|
1249
|
+
)
|
|
1250
|
+
in_shape = tuple(int(size) for size in in_shape)
|
|
1251
|
+
# the house class's own __init__ builds networks; a wrapper is given them
|
|
1252
|
+
Model.__init__(self, {'in_shape': in_shape, **arguments})
|
|
1253
|
+
for name, network in networks.items():
|
|
1254
|
+
setattr(self, name, network)
|
|
1255
|
+
self._in_shape = in_shape
|
|
1256
|
+
self._network = NETWORK_KINDS[self.network_kind]['networks'](self)
|
|
1257
|
+
|
|
1258
|
+
@property
|
|
1259
|
+
def in_features(self):
|
|
1260
|
+
"""The number of values in one sample, the product of 'in_shape'; read-only."""
|
|
1261
|
+
return math.prod(self.in_shape)
|
|
1262
|
+
|
|
1263
|
+
@property
|
|
1264
|
+
def hidden_size(self):
|
|
1265
|
+
"""The output of every Layer but the last, in order: a width, or a conv Layer's (channels, height, width)."""
|
|
1266
|
+
layers = [layer for network in self.network for layer in network.layer]
|
|
1267
|
+
return tuple(
|
|
1268
|
+
layer.out_shape[0] if len(layer.out_shape) == 1 else layer.out_shape
|
|
1269
|
+
for layer in layers[:-1]
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
@property
|
|
1273
|
+
def activation_name(self):
|
|
1274
|
+
"""The first Layer's activation, e.g. "relu", the one the views label as the hidden activation."""
|
|
1275
|
+
return self.network[0].layer[0].activation
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
class ExternalClassificationModel(_ExternalModel, ClassificationModel):
|
|
1279
|
+
"""
|
|
1280
|
+
A classification network built outside mcode-neural, e.g. torchvision's VGG11, as a Model.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
classifier: the network, an nn.Module whose outputs are one logit per class; its loss,
|
|
1284
|
+
nn.CrossEntropyLoss, applies the softmax.
|
|
1285
|
+
in_shape: one sample's shape as the network reads it, e.g. (3, 224, 224); given, since a
|
|
1286
|
+
network with an adaptive pool reads many sizes.
|
|
1287
|
+
|
|
1288
|
+
Note:
|
|
1289
|
+
It trains, tests, predicts, shows, and saves as a 'ClassificationModel' does. Its Layers
|
|
1290
|
+
are found by one hooked pass, as every Model's are, so a network that runs a module twice
|
|
1291
|
+
in one pass, e.g. ResNet's shared ReLU, fails by name. 'hidden_size' and
|
|
1292
|
+
'activation_name' are read from its Layers. 'model_load()' takes the same network built
|
|
1293
|
+
again: 'model_load(path, classifier=...)'.
|
|
1294
|
+
|
|
1295
|
+
Example:
|
|
1296
|
+
>>> vgg = ExternalClassificationModel(models.vgg11(weights=None), (3, 32, 32)).to(device)
|
|
1297
|
+
>>> model_replace_output(vgg, 10)
|
|
1298
|
+
"""
|
|
1299
|
+
|
|
1300
|
+
def __init__(self, classifier, in_shape):
|
|
1301
|
+
self._wrap({'classifier': classifier}, in_shape, {})
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
class ExternalRegressionModel(_ExternalModel, RegressionModel):
|
|
1305
|
+
"""
|
|
1306
|
+
A regression network built outside mcode-neural as a Model.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
regressor: the network, an nn.Module whose outputs are the predicted values, one per target.
|
|
1310
|
+
in_shape: one sample's shape as the network reads it, e.g. (13,) or (3, 64, 64).
|
|
1311
|
+
|
|
1312
|
+
Note:
|
|
1313
|
+
It trains, tests, predicts, shows, and saves as a 'RegressionModel' does, its Layers read
|
|
1314
|
+
as 'ExternalClassificationModel' reads them. 'model_load()' takes the same network built
|
|
1315
|
+
again: 'model_load(path, regressor=...)'.
|
|
1316
|
+
|
|
1317
|
+
Example:
|
|
1318
|
+
>>> model = ExternalRegressionModel(my_regressor, (13,)).to(device)
|
|
1319
|
+
"""
|
|
1320
|
+
|
|
1321
|
+
def __init__(self, regressor, in_shape):
|
|
1322
|
+
self._wrap({'regressor': regressor}, in_shape, {})
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
class ExternalAutoencoderModel(_ExternalModel, AutoencoderModel):
|
|
1326
|
+
"""
|
|
1327
|
+
An autoencoder built outside mcode-neural, its encoder and decoder, as a Model.
|
|
1328
|
+
|
|
1329
|
+
Args:
|
|
1330
|
+
encoder: the network that compresses each sample to its latent code, an nn.Module.
|
|
1331
|
+
decoder: the network that rebuilds each sample from its code, an nn.Module whose output
|
|
1332
|
+
is the sample's shape, through its 'output_activation'.
|
|
1333
|
+
in_shape: one sample's shape as the encoder reads it, e.g. (1, 28, 28).
|
|
1334
|
+
output_activation: the decoder's output style, one of 'AUTOENCODER_OUTPUTS', as
|
|
1335
|
+
'AutoencoderModel' takes it: "sigmoid" for a decoder ending in
|
|
1336
|
+
nn.Sigmoid, "tanh" for nn.Tanh, "linear" for none.
|
|
1337
|
+
|
|
1338
|
+
Note:
|
|
1339
|
+
It trains, tests, encodes, decodes, shows, and saves as an 'AutoencoderModel' does, its
|
|
1340
|
+
Layers read as 'ExternalClassificationModel' reads them. 'model_load()' takes both
|
|
1341
|
+
networks built again: 'model_load(path, encoder=..., decoder=...)'.
|
|
1342
|
+
|
|
1343
|
+
Example:
|
|
1344
|
+
>>> model = ExternalAutoencoderModel(my_encoder, my_decoder, (1, 28, 28), "sigmoid").to(device)
|
|
1345
|
+
"""
|
|
1346
|
+
|
|
1347
|
+
def __init__(self, encoder, decoder, in_shape, output_activation):
|
|
1348
|
+
_check_output_activation(output_activation)
|
|
1349
|
+
self._wrap(
|
|
1350
|
+
{'encoder': encoder, 'decoder': decoder},
|
|
1351
|
+
in_shape,
|
|
1352
|
+
{'output_activation': output_activation},
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
@property
|
|
1356
|
+
def output_activation_name(self):
|
|
1357
|
+
"""The decoder's output style, its 'output_activation' argument; read-only."""
|
|
1358
|
+
return self.arguments['output_activation']
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
# endregion
|
|
1362
|
+
|
|
1363
|
+
# region P R I V A T E F U N C T I O N S
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _check_output_activation(output_activation):
|
|
1367
|
+
"""Raises a ValueError unless 'output_activation' is one of 'AUTOENCODER_OUTPUTS'."""
|
|
1368
|
+
if output_activation not in AUTOENCODER_OUTPUTS:
|
|
1369
|
+
raise ValueError(
|
|
1370
|
+
f"'output_activation' must be one of {list(AUTOENCODER_OUTPUTS)}, got {output_activation!r}"
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _check_model(model):
|
|
1375
|
+
"""
|
|
1376
|
+
Raises a TypeError unless 'model' is a mcode-neural Model, for every function that takes one.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
model: the caller's model.
|
|
1380
|
+
"""
|
|
1381
|
+
if not isinstance(model, Model):
|
|
1382
|
+
raise TypeError(
|
|
1383
|
+
f"'model' must be a mcode-neural Model, got {type(model).__name__}"
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _flattened(outputs, targets):
|
|
1388
|
+
"""
|
|
1389
|
+
Returns 'outputs' reshaped to the targets' shape, raising a ValueError when their sizes differ.
|
|
1390
|
+
|
|
1391
|
+
Args:
|
|
1392
|
+
outputs: a batch's outputs, e.g. (N, 1).
|
|
1393
|
+
targets: what its loss compares them to, e.g. (N,).
|
|
1394
|
+
"""
|
|
1395
|
+
# (N, 2) outputs for (N,) targets would reshape to nothing sensible
|
|
1396
|
+
if outputs.numel() != targets.numel():
|
|
1397
|
+
raise ValueError(
|
|
1398
|
+
f"'model' outputs {tuple(outputs.shape)} for targets {tuple(targets.shape)};"
|
|
1399
|
+
" its 'out_features' must match the number of targets"
|
|
1400
|
+
)
|
|
1401
|
+
return outputs.reshape(targets.shape)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _article(word):
|
|
1405
|
+
"""
|
|
1406
|
+
Returns 'word' after its English article, e.g. "a regression" or "an autoencoder".
|
|
1407
|
+
|
|
1408
|
+
Args:
|
|
1409
|
+
word: the word the article leads, e.g. a model's 'kind'.
|
|
1410
|
+
"""
|
|
1411
|
+
return f'{"an" if word[0] in "aeiou" else "a"} {word}'
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _arguments(hyperparams):
|
|
1415
|
+
"""
|
|
1416
|
+
Returns a "create" event's arguments as the constructor takes them: those given, then those left at their defaults.
|
|
1417
|
+
|
|
1418
|
+
Args:
|
|
1419
|
+
hyperparams: the "create" event's hyperparams, {name: value, ..., "defaults": {name: value}}.
|
|
1420
|
+
"""
|
|
1421
|
+
given = {
|
|
1422
|
+
key: value for key, value in hyperparams.items() if key != HISTORY_DEFAULTS
|
|
1423
|
+
}
|
|
1424
|
+
return given | hyperparams[HISTORY_DEFAULTS]
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _event(event, time, hyperparams, results):
|
|
1428
|
+
"""
|
|
1429
|
+
Returns one history event, {"event", "time", "hyperparams", "results"}.
|
|
1430
|
+
|
|
1431
|
+
Args:
|
|
1432
|
+
event: the step, "create", "data", "train", or "load".
|
|
1433
|
+
time: its local time, e.g. "2026-10-29 14:03:12-04:00"; None when not recorded.
|
|
1434
|
+
hyperparams: what the step was given, plain values; None when not recorded.
|
|
1435
|
+
results: what the step produced, plain values; None when not recorded.
|
|
1436
|
+
"""
|
|
1437
|
+
return {
|
|
1438
|
+
'event': event,
|
|
1439
|
+
'time': time,
|
|
1440
|
+
'hyperparams': hyperparams,
|
|
1441
|
+
'results': results,
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _plain(value, name):
|
|
1446
|
+
"""
|
|
1447
|
+
Returns 'value' as plain values a saved model can keep: None, bool, str, int, float, and lists, tuples, and dicts of them.
|
|
1448
|
+
|
|
1449
|
+
Args:
|
|
1450
|
+
value: the value to record, e.g. an optimizer's settings; a NumPy or torch number, array,
|
|
1451
|
+
or tensor becomes the plain number or list it holds.
|
|
1452
|
+
name: its dotted name, e.g. "hyperparams.optimizer", named when it cannot be kept.
|
|
1453
|
+
"""
|
|
1454
|
+
if value is None:
|
|
1455
|
+
return value
|
|
1456
|
+
if isinstance(value, (bool, np.bool_)):
|
|
1457
|
+
return bool(value)
|
|
1458
|
+
# a str subclass, e.g. torch's TorchVersion, would be saved as its own class
|
|
1459
|
+
if isinstance(value, str):
|
|
1460
|
+
return str(value)
|
|
1461
|
+
if isinstance(value, numbers.Integral):
|
|
1462
|
+
return int(value)
|
|
1463
|
+
if isinstance(value, numbers.Real):
|
|
1464
|
+
return float(value)
|
|
1465
|
+
if isinstance(value, (torch.Tensor, np.ndarray)):
|
|
1466
|
+
return _plain(value.tolist(), name)
|
|
1467
|
+
if isinstance(value, (list, tuple)):
|
|
1468
|
+
items = [_plain(item, f'{name}[{i}]') for i, item in enumerate(value)]
|
|
1469
|
+
return tuple(items) if isinstance(value, tuple) else items
|
|
1470
|
+
if isinstance(value, dict):
|
|
1471
|
+
return {key: _plain(item, f'{name}.{key}') for key, item in value.items()}
|
|
1472
|
+
raise TypeError(
|
|
1473
|
+
f'{name!r} holds a {type(value).__name__}, which a saved model cannot keep'
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
@contextmanager
|
|
1478
|
+
def _seeded(seed):
|
|
1479
|
+
"""
|
|
1480
|
+
Seeds the model weights built inside it, leaving the Notebook's own random streams as they were.
|
|
1481
|
+
|
|
1482
|
+
Args:
|
|
1483
|
+
seed: a whole number that seeds NumPy's global RNG and torch's CPU generator inside the
|
|
1484
|
+
block, both restored after it; None leaves both as they are, unseeded.
|
|
1485
|
+
"""
|
|
1486
|
+
if seed is None:
|
|
1487
|
+
yield
|
|
1488
|
+
return
|
|
1489
|
+
state = np.random.get_state()
|
|
1490
|
+
np.random.seed(seed)
|
|
1491
|
+
try:
|
|
1492
|
+
# the CPU generator only: torch.manual_seed() would also reseed CUDA, outside the fork
|
|
1493
|
+
with torch.random.fork_rng(devices=[]):
|
|
1494
|
+
torch.default_generator.manual_seed(seed)
|
|
1495
|
+
yield
|
|
1496
|
+
finally:
|
|
1497
|
+
np.random.set_state(state)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _latest_train(model):
|
|
1501
|
+
"""
|
|
1502
|
+
Returns the model's latest "train" event, or None before any training.
|
|
1503
|
+
|
|
1504
|
+
Args:
|
|
1505
|
+
model: a Model.
|
|
1506
|
+
"""
|
|
1507
|
+
trains = [event for event in model.history if event['event'] == 'train']
|
|
1508
|
+
return trains[-1] if trains else None
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _score_batch_size(model):
|
|
1512
|
+
"""
|
|
1513
|
+
Returns how many samples a model scores at once: its latest training's 'batch_size', else 'SCORE_BATCH_SIZE'.
|
|
1514
|
+
|
|
1515
|
+
Args:
|
|
1516
|
+
model: a Model.
|
|
1517
|
+
|
|
1518
|
+
Note:
|
|
1519
|
+
Scoring keeps no gradients, so a batch that trained fits. A model not yet trained, or
|
|
1520
|
+
saved by a version whose neuron trainer took no batch size, scores 'SCORE_BATCH_SIZE'.
|
|
1521
|
+
"""
|
|
1522
|
+
train = _latest_train(model)
|
|
1523
|
+
hyperparams = None if train is None else train['hyperparams']
|
|
1524
|
+
if hyperparams is None or 'batch_size' not in hyperparams:
|
|
1525
|
+
return SCORE_BATCH_SIZE
|
|
1526
|
+
return hyperparams['batch_size']
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _upgraded(saved):
|
|
1530
|
+
"""
|
|
1531
|
+
Returns a file saved by a pre-release build, which kept no history, in the shape 'model_save()' writes now.
|
|
1532
|
+
|
|
1533
|
+
Args:
|
|
1534
|
+
saved: what 'torch.load()' read: 'class', 'arguments', 'weights', 'data', 'curves', and
|
|
1535
|
+
'best_epoch'.
|
|
1536
|
+
|
|
1537
|
+
Note:
|
|
1538
|
+
The one approved fallback. The history is built from what the file held: "create" from
|
|
1539
|
+
its arguments, "data" when it held data, and "train" when it held curves. A fact it never
|
|
1540
|
+
held, e.g. a time, a split, or a trainer's settings, is None, "not recorded".
|
|
1541
|
+
"""
|
|
1542
|
+
history = [_event('create', None, saved['arguments'], None)]
|
|
1543
|
+
data = saved['data']
|
|
1544
|
+
if data is not None:
|
|
1545
|
+
data = data | {'split': None}
|
|
1546
|
+
history.append(_event('data', None, None, None))
|
|
1547
|
+
if saved['curves']:
|
|
1548
|
+
results = {
|
|
1549
|
+
'curves': saved['curves'],
|
|
1550
|
+
'best_epoch': saved['best_epoch'],
|
|
1551
|
+
'tests': {},
|
|
1552
|
+
}
|
|
1553
|
+
history.append(_event('train', None, None, results))
|
|
1554
|
+
return {
|
|
1555
|
+
'class': saved['class'],
|
|
1556
|
+
'weights': saved['weights'],
|
|
1557
|
+
'data': data,
|
|
1558
|
+
'history': history,
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _check_networks(path, cls, networks):
|
|
1563
|
+
"""
|
|
1564
|
+
Raises a ValueError unless 'networks' are the ones the saved class takes: a wrapped Model's, by name, or none.
|
|
1565
|
+
|
|
1566
|
+
Args:
|
|
1567
|
+
path: the file being loaded, for the messages.
|
|
1568
|
+
cls: the saved Model class.
|
|
1569
|
+
networks: the networks 'model_load()' was given, by name.
|
|
1570
|
+
"""
|
|
1571
|
+
if not cls.is_external:
|
|
1572
|
+
if networks:
|
|
1573
|
+
raise ValueError(
|
|
1574
|
+
f'{path!r} holds a {cls.__name__!r}, which builds its own networks; give none, got {list(networks)}'
|
|
1575
|
+
)
|
|
1576
|
+
return
|
|
1577
|
+
names = list(cls.sources)
|
|
1578
|
+
if sorted(networks) != sorted(names):
|
|
1579
|
+
raise ValueError(
|
|
1580
|
+
f'{path!r} holds a {cls.__name__!r}; give its networks built again, by name, {names},'
|
|
1581
|
+
f' e.g. model_load(path, {names[0]}=...), got {list(networks)}'
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _check_fit(path, model, weights):
|
|
1586
|
+
"""
|
|
1587
|
+
Raises a ValueError naming the first weight the file and the model's networks do not hold in one shape.
|
|
1588
|
+
|
|
1589
|
+
Args:
|
|
1590
|
+
path: the file being loaded, for the messages.
|
|
1591
|
+
model: a wrapped Model, built from the networks given, its saved changes replayed.
|
|
1592
|
+
weights: the file's weights, {key: tensor}, as 'model.weights()' saved them.
|
|
1593
|
+
"""
|
|
1594
|
+
shapes = {key: tuple(value.shape) for key, value in model.state_dict().items()}
|
|
1595
|
+
for key, value in weights.items():
|
|
1596
|
+
shape = tuple(value.shape)
|
|
1597
|
+
if key not in shapes:
|
|
1598
|
+
raise ValueError(
|
|
1599
|
+
f'{path!r} holds {_path_text(key)} {shape}; the networks given have no {_path_text(key)}'
|
|
1600
|
+
)
|
|
1601
|
+
if shapes[key] != shape:
|
|
1602
|
+
raise ValueError(
|
|
1603
|
+
f'{path!r} holds {_path_text(key)} {shape}; the networks given have {shapes[key]}'
|
|
1604
|
+
)
|
|
1605
|
+
for key, shape in shapes.items():
|
|
1606
|
+
if key not in weights:
|
|
1607
|
+
raise ValueError(
|
|
1608
|
+
f'the networks given have {_path_text(key)} {shape}, which {path!r} does not hold'
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _path_text(path):
|
|
1613
|
+
"""
|
|
1614
|
+
Returns a module path as the code that reaches it reads, e.g. "classifier.6" as "classifier[6]".
|
|
1615
|
+
|
|
1616
|
+
Args:
|
|
1617
|
+
path: a path as 'named_modules()' names it, or already in that form, e.g. "features".
|
|
1618
|
+
"""
|
|
1619
|
+
return re.sub(r'\.(\d+)', r'[\1]', path)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _module_path(text):
|
|
1623
|
+
"""
|
|
1624
|
+
Returns a module path as 'named_modules()' names it, e.g. "classifier[6]" as "classifier.6".
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
text: a path either way, e.g. "classifier[6]" or "classifier.6".
|
|
1628
|
+
"""
|
|
1629
|
+
return re.sub(r'\[(\d+)\]', r'.\1', text)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _parts(model, parts):
|
|
1633
|
+
"""
|
|
1634
|
+
Returns the submodules 'parts' names, e.g. ("features",), or the whole model when none, failing by name.
|
|
1635
|
+
|
|
1636
|
+
Args:
|
|
1637
|
+
model: a 'torch.nn.Module', a network Model or an external network.
|
|
1638
|
+
parts: submodule paths, e.g. "features" or "classifier[6]" ("classifier.6" reads the same).
|
|
1639
|
+
"""
|
|
1640
|
+
if not isinstance(model, nn.Module):
|
|
1641
|
+
raise TypeError(
|
|
1642
|
+
f"'model' must be a PyTorch network, e.g. ClassificationModel or VGG11, got {type(model).__name__};"
|
|
1643
|
+
' a single NumPy neuron has no layers to freeze or replace'
|
|
1644
|
+
)
|
|
1645
|
+
for path in parts:
|
|
1646
|
+
try:
|
|
1647
|
+
model.get_submodule(_module_path(path))
|
|
1648
|
+
except AttributeError:
|
|
1649
|
+
top = [name for name, _ in model.named_children()]
|
|
1650
|
+
raise ValueError(
|
|
1651
|
+
f"'model' has no part {path!r}; its top-level parts are {top}"
|
|
1652
|
+
) from None
|
|
1653
|
+
return [model.get_submodule(_module_path(path)) for path in parts] or [model]
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _trainable(model):
|
|
1657
|
+
"""Returns (trainable, total) parameter counts of a network."""
|
|
1658
|
+
parameters = list(model.parameters())
|
|
1659
|
+
trainable = sum(p.numel() for p in parameters if p.requires_grad)
|
|
1660
|
+
return trainable, sum(p.numel() for p in parameters)
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _set_trainable(model, parts, trains):
|
|
1664
|
+
"""
|
|
1665
|
+
Sets whether every parameter of each part trains, and returns the network's (trainable, total) counts.
|
|
1666
|
+
|
|
1667
|
+
Args:
|
|
1668
|
+
model: a network.
|
|
1669
|
+
parts: submodule paths; empty for the whole network.
|
|
1670
|
+
trains: True to unfreeze, False to freeze.
|
|
1671
|
+
"""
|
|
1672
|
+
for module in _parts(model, parts):
|
|
1673
|
+
for parameter in module.parameters():
|
|
1674
|
+
parameter.requires_grad_(trains)
|
|
1675
|
+
return _trainable(model)
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def _output_layer(model):
|
|
1679
|
+
"""Returns (path, module) of a network's output layer, its last nn.Linear in module order, failing by name."""
|
|
1680
|
+
linears = [
|
|
1681
|
+
(path, module)
|
|
1682
|
+
for path, module in model.named_modules()
|
|
1683
|
+
if isinstance(module, nn.Linear)
|
|
1684
|
+
]
|
|
1685
|
+
if not linears:
|
|
1686
|
+
raise ValueError(
|
|
1687
|
+
f"'model' {type(model).__name__} has no nn.Linear output layer to replace"
|
|
1688
|
+
)
|
|
1689
|
+
return linears[-1]
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def _replace_output(model, out_features):
|
|
1693
|
+
"""
|
|
1694
|
+
Replaces a network's output layer with a new nn.Linear of 'out_features', and returns (path, old layer, new layer).
|
|
1695
|
+
|
|
1696
|
+
Args:
|
|
1697
|
+
model: a network.
|
|
1698
|
+
out_features: the new layer's outputs, already checked.
|
|
1699
|
+
|
|
1700
|
+
Note:
|
|
1701
|
+
The new layer keeps the old one's 'in_features', bias, device, and dtype; its weights are
|
|
1702
|
+
new. A Model's Network views are built again, so they read the new layer.
|
|
1703
|
+
"""
|
|
1704
|
+
path, old = _output_layer(model)
|
|
1705
|
+
new = nn.Linear(old.in_features, out_features, bias=old.bias is not None).to(
|
|
1706
|
+
device=old.weight.device, dtype=old.weight.dtype
|
|
1707
|
+
)
|
|
1708
|
+
parent, _, name = path.rpartition('.')
|
|
1709
|
+
setattr(model.get_submodule(parent), name, new)
|
|
1710
|
+
if isinstance(model, Model):
|
|
1711
|
+
model._network = NETWORK_KINDS[model.network_kind]['networks'](model)
|
|
1712
|
+
return path, old, new
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def _gpu_memory(device):
|
|
1716
|
+
"""
|
|
1717
|
+
Returns a CUDA device's (total, cap) in GiB; the cap is the share 'model_device()' set, None when none was.
|
|
1718
|
+
|
|
1719
|
+
Args:
|
|
1720
|
+
device: a CUDA device.
|
|
1721
|
+
|
|
1722
|
+
Note:
|
|
1723
|
+
The cap is read back from PyTorch, which keeps the fraction; the library keeps no copy.
|
|
1724
|
+
"""
|
|
1725
|
+
total = torch.cuda.get_device_properties(device).total_memory / 2**30
|
|
1726
|
+
fraction = torch.cuda.get_per_process_memory_fraction(device)
|
|
1727
|
+
return total, (fraction * total if fraction < 1 else None)
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _peaks(device):
|
|
1731
|
+
"""
|
|
1732
|
+
Returns the peak GPU memory since its last reset, {"peak_allocated_gib", "peak_reserved_gib"}, in GiB to 3 places.
|
|
1733
|
+
|
|
1734
|
+
Args:
|
|
1735
|
+
device: a CUDA device.
|
|
1736
|
+
|
|
1737
|
+
Note:
|
|
1738
|
+
Reserved is what PyTorch holds from the driver, and what the cap of 'model_device()' bounds;
|
|
1739
|
+
allocated is the part of it tensors use.
|
|
1740
|
+
"""
|
|
1741
|
+
return {
|
|
1742
|
+
'peak_allocated_gib': round(torch.cuda.max_memory_allocated(device) / 2**30, 3),
|
|
1743
|
+
'peak_reserved_gib': round(torch.cuda.max_memory_reserved(device) / 2**30, 3),
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def _out_of_memory(phase, batch_size, device):
|
|
1748
|
+
"""
|
|
1749
|
+
Returns a torch.OutOfMemoryError naming the phase and batch size that ran past the GPU memory this process may use.
|
|
1750
|
+
|
|
1751
|
+
Args:
|
|
1752
|
+
phase: the phase whose batch failed, e.g. "train".
|
|
1753
|
+
batch_size: the batch size that failed.
|
|
1754
|
+
device: the CUDA device the batch ran on.
|
|
1755
|
+
|
|
1756
|
+
Note:
|
|
1757
|
+
The cap is read back from PyTorch, which keeps the fraction 'model_device()' set; with no
|
|
1758
|
+
cap the limit is the GPU's whole memory.
|
|
1759
|
+
"""
|
|
1760
|
+
total, cap = _gpu_memory(device)
|
|
1761
|
+
limit = (
|
|
1762
|
+
f'the {cap:.2f} GiB cap' if cap is not None else f"the GPU's {total:.2f} GiB"
|
|
1763
|
+
)
|
|
1764
|
+
return torch.OutOfMemoryError(
|
|
1765
|
+
f'phase {phase!r} at batch_size={batch_size} needs more than {limit}; try a smaller batch_size'
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
# endregion
|
|
1770
|
+
|
|
1771
|
+
# region P U B L I C F U N C T I O N S
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def model_test(model, phase):
|
|
1775
|
+
"""
|
|
1776
|
+
Scores a trained model on one labeled phase of its data, and keeps the metrics in its history.
|
|
1777
|
+
|
|
1778
|
+
Args:
|
|
1779
|
+
model: a Model trained by 'train_model()'.
|
|
1780
|
+
phase: the labeled phase to score, e.g. "test", a phase of 'model.data'.
|
|
1781
|
+
|
|
1782
|
+
Returns:
|
|
1783
|
+
'predict_metrics(model, phase)': a classifier's {"accuracy", "auc"}, a regressor's
|
|
1784
|
+
{"mae", "rmse", "r2"}, or an autoencoder's {"mse", "rmse"}.
|
|
1785
|
+
|
|
1786
|
+
Note:
|
|
1787
|
+
The metrics describe the weights the latest training kept, so they are kept in that
|
|
1788
|
+
"train" event's results as "tests": {phase: metrics}. Testing the same phase again
|
|
1789
|
+
replaces its metrics, so a re-run cell adds nothing; training again starts a new event.
|
|
1790
|
+
The phase is scored in batches of the training's 'batch_size'; on a GPU, the kept
|
|
1791
|
+
metrics also hold the scoring's peak memory, "peak_allocated_gib" and
|
|
1792
|
+
"peak_reserved_gib", as the trainer's results do.
|
|
1793
|
+
|
|
1794
|
+
Example:
|
|
1795
|
+
>>> model = train_model(model, optimizer, scheduler, num_epochs=50, batch_size=10)
|
|
1796
|
+
>>> model_test(model, "test")
|
|
1797
|
+
{'accuracy': 0.9615, 'auc': 0.9912}
|
|
1798
|
+
"""
|
|
1799
|
+
# predict.py imports this module, so its scorer is imported only when first called
|
|
1800
|
+
from .predict import predict_metrics
|
|
1801
|
+
|
|
1802
|
+
_check_model(model)
|
|
1803
|
+
train = _latest_train(model)
|
|
1804
|
+
if train is None:
|
|
1805
|
+
raise ValueError(
|
|
1806
|
+
"'model' has no training to test; train it with 'train_model()' first"
|
|
1807
|
+
)
|
|
1808
|
+
device = next(model.parameters()).device if isinstance(model, nn.Module) else None
|
|
1809
|
+
is_cuda = device is not None and device.type == 'cuda'
|
|
1810
|
+
if is_cuda:
|
|
1811
|
+
torch.cuda.reset_peak_memory_stats(device)
|
|
1812
|
+
metrics = predict_metrics(model, phase)
|
|
1813
|
+
kept = metrics | _peaks(device) if is_cuda else metrics
|
|
1814
|
+
train['results']['tests'][phase] = _plain(kept, f'tests.{phase}')
|
|
1815
|
+
return metrics
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def model_save(model, path):
|
|
1819
|
+
"""
|
|
1820
|
+
Saves a model with what it knows about itself: its class, weights, data contract, and
|
|
1821
|
+
history as a PyTorch .pt file.
|
|
1822
|
+
|
|
1823
|
+
Args:
|
|
1824
|
+
model: a Model, e.g. trained by 'train_model()'.
|
|
1825
|
+
path: the file to write, e.g. "wine_neuron.pt".
|
|
1826
|
+
|
|
1827
|
+
Note:
|
|
1828
|
+
The datasets are not saved, only their contract: the feature names, the classes, the
|
|
1829
|
+
DataFrame contract, and how they were split; a loaded model scores new rows added by
|
|
1830
|
+
'data_frame_phase()'. The history holds the arguments the model is built again from, and
|
|
1831
|
+
every training's curves. Every value saved is a tensor, number, string, list, tuple, or
|
|
1832
|
+
dict, so 'model_load()' reads the file with 'weights_only=True', which runs no pickled code.
|
|
1833
|
+
|
|
1834
|
+
Example:
|
|
1835
|
+
>>> model_save(model, "wine_neuron.pt")
|
|
1836
|
+
"""
|
|
1837
|
+
_check_model(model)
|
|
1838
|
+
data = None
|
|
1839
|
+
if model._data is not None:
|
|
1840
|
+
frame = model._data.frame
|
|
1841
|
+
# a torch dtype is stored by its name, e.g. "int64", a plain string
|
|
1842
|
+
if frame is not None:
|
|
1843
|
+
frame = frame | {'label_dtype': str(frame['label_dtype']).split('.')[-1]}
|
|
1844
|
+
data = {
|
|
1845
|
+
'kind': model._data.kind,
|
|
1846
|
+
'feature': model._data.feature,
|
|
1847
|
+
'classes': model._data.classes,
|
|
1848
|
+
'frame': frame,
|
|
1849
|
+
'split': model._data.split,
|
|
1850
|
+
}
|
|
1851
|
+
torch.save(
|
|
1852
|
+
{
|
|
1853
|
+
'class': type(model).__name__,
|
|
1854
|
+
'weights': model.weights(),
|
|
1855
|
+
'data': data,
|
|
1856
|
+
'history': model.history,
|
|
1857
|
+
},
|
|
1858
|
+
path,
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def model_load(path, **networks):
|
|
1863
|
+
"""
|
|
1864
|
+
Loads a model saved by 'model_save()' from a PyTorch .pt file: the same class, weights, data contract, and history.
|
|
1865
|
+
|
|
1866
|
+
Args:
|
|
1867
|
+
path: the file 'model_save()' wrote.
|
|
1868
|
+
networks: a wrapped Model's networks, built again by the caller, each by its name in its
|
|
1869
|
+
class's 'sources', e.g. classifier=models.vgg11(weights=None); none for every
|
|
1870
|
+
other Model, which builds its own.
|
|
1871
|
+
|
|
1872
|
+
Returns:
|
|
1873
|
+
the model, on the CPU, or on a wrapped Model's networks' device; 'model.data' holds the
|
|
1874
|
+
saved contract and no datasets, so add new rows with 'data_frame_phase()' or set new
|
|
1875
|
+
datasets before scoring them. Its history is the saved one, then a "load" event naming
|
|
1876
|
+
'path'.
|
|
1877
|
+
|
|
1878
|
+
Note:
|
|
1879
|
+
The model's class must be defined, i.e. its module imported, before it is loaded. A file
|
|
1880
|
+
saved by a pre-release build that kept no history loads with one built from what it held:
|
|
1881
|
+
its arguments, data, and curves; a time or setting it never held reads "not recorded".
|
|
1882
|
+
A pre-release file that kept every argument as given has none reading "(default)".
|
|
1883
|
+
The history's "replace_output", "freeze", and "unfreeze" events are replayed in order
|
|
1884
|
+
before the weights load, so a swapped output layer fits and frozen parts stay frozen.
|
|
1885
|
+
A wrapped Model's networks need only be built as the original was before those changes,
|
|
1886
|
+
e.g. VGG11 with its 1,000 outputs; a saved weight they do not hold, or hold in another
|
|
1887
|
+
shape, fails by name before any weight loads.
|
|
1888
|
+
|
|
1889
|
+
Example:
|
|
1890
|
+
>>> model = model_load("wine_neuron.pt")
|
|
1891
|
+
>>> data_frame_phase(model, "new", new_wines)
|
|
1892
|
+
>>> labels, probabilities = predict_classes(model, "new")
|
|
1893
|
+
>>> vgg = model_load("vgg11_fashion.pt", classifier=models.vgg11(weights=None))
|
|
1894
|
+
"""
|
|
1895
|
+
saved = torch.load(path, map_location='cpu', weights_only=True)
|
|
1896
|
+
if 'history' not in saved:
|
|
1897
|
+
saved = _upgraded(saved)
|
|
1898
|
+
name = saved['class']
|
|
1899
|
+
if name not in Model.CLASSES:
|
|
1900
|
+
raise ValueError(
|
|
1901
|
+
f'{path!r} holds a {name!r}, which is not a defined Model class; import its module first'
|
|
1902
|
+
)
|
|
1903
|
+
create = saved['history'][0]
|
|
1904
|
+
if HISTORY_DEFAULTS not in create['hyperparams']:
|
|
1905
|
+
create['hyperparams'] = create['hyperparams'] | {HISTORY_DEFAULTS: {}}
|
|
1906
|
+
cls = Model.CLASSES[name]
|
|
1907
|
+
_check_networks(path, cls, networks)
|
|
1908
|
+
model = cls(**networks, **_arguments(create['hyperparams']))
|
|
1909
|
+
for event in saved['history']:
|
|
1910
|
+
if event['event'] in MODEL_CHANGES:
|
|
1911
|
+
MODEL_CHANGES[event['event']](model, event['hyperparams'])
|
|
1912
|
+
if model.is_external:
|
|
1913
|
+
_check_fit(path, model, saved['weights'])
|
|
1914
|
+
model.load_weights(saved['weights'])
|
|
1915
|
+
data = saved['data']
|
|
1916
|
+
if data is not None:
|
|
1917
|
+
if 'kind' not in data:
|
|
1918
|
+
raise ValueError(
|
|
1919
|
+
f"{path!r} was saved by a pre-release build of mcode-neural, so its data has no 'kind'; re-save it from its Notebook"
|
|
1920
|
+
)
|
|
1921
|
+
frame = data['frame']
|
|
1922
|
+
if frame is not None:
|
|
1923
|
+
frame = frame | {'label_dtype': getattr(torch, frame['label_dtype'])}
|
|
1924
|
+
model.data = Data(
|
|
1925
|
+
data['kind'],
|
|
1926
|
+
{},
|
|
1927
|
+
data['feature'],
|
|
1928
|
+
data['classes'],
|
|
1929
|
+
frame,
|
|
1930
|
+
data['split'],
|
|
1931
|
+
)
|
|
1932
|
+
model.history = saved['history']
|
|
1933
|
+
model._record('load', {'path': str(path)}, {})
|
|
1934
|
+
return model
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def model_device(fraction=DEVICE_FRACTION):
|
|
1938
|
+
"""
|
|
1939
|
+
Returns the device to train on, CUDA when available, else the CPU, and caps this process's GPU memory.
|
|
1940
|
+
|
|
1941
|
+
Args:
|
|
1942
|
+
fraction: the share of the GPU's memory this process may use, a number above 0 and at
|
|
1943
|
+
most 1; 'DEVICE_FRACTION', 0.75, leaves the rest to the desktop and other processes.
|
|
1944
|
+
|
|
1945
|
+
Returns:
|
|
1946
|
+
torch.device('cuda', n), the current GPU, or torch.device('cpu') when CUDA is not available.
|
|
1947
|
+
|
|
1948
|
+
Note:
|
|
1949
|
+
On CUDA, PyTorch's memory allocator is capped at 'fraction' of the GPU's memory, so a
|
|
1950
|
+
batch too big raises torch.OutOfMemoryError at once, which 'train_model()' re-raises
|
|
1951
|
+
naming the phase and batch size, instead of spilling into shared system RAM and running
|
|
1952
|
+
many times slower. The cap bounds PyTorch's tensors; the CUDA context, a few hundred MB,
|
|
1953
|
+
sits outside it. PyTorch's cached free blocks are returned to the driver as the cap is
|
|
1954
|
+
set, since a block cached before it could be reused past it. On a CPU no cap is set. It
|
|
1955
|
+
prints the device, and on CUDA the GPU's name,
|
|
1956
|
+
its memory, and the cap, so the Notebook's output records what the run had. Calling it
|
|
1957
|
+
again sets the cap again; a new 'fraction' replaces the old one.
|
|
1958
|
+
|
|
1959
|
+
Example:
|
|
1960
|
+
>>> device = model_device()
|
|
1961
|
+
Device: cuda:0
|
|
1962
|
+
GPU: NVIDIA RTX 2000 Ada Generation Laptop GPU
|
|
1963
|
+
Memory: 8.00 GiB
|
|
1964
|
+
Cap: 6.00 GiB (75%)
|
|
1965
|
+
>>> model = ClassificationModel(in_features=4, hidden_size=(64, 32), out_features=2).to(device)
|
|
1966
|
+
"""
|
|
1967
|
+
if (
|
|
1968
|
+
isinstance(fraction, bool)
|
|
1969
|
+
or not isinstance(fraction, numbers.Real)
|
|
1970
|
+
or not 0 < fraction <= 1
|
|
1971
|
+
):
|
|
1972
|
+
raise ValueError(
|
|
1973
|
+
f"'fraction' must be a number above 0 and at most 1, got {fraction!r}"
|
|
1974
|
+
)
|
|
1975
|
+
if not torch.cuda.is_available():
|
|
1976
|
+
print('Device: cpu')
|
|
1977
|
+
print(' GPU: none; CUDA is not available')
|
|
1978
|
+
return torch.device('cpu')
|
|
1979
|
+
|
|
1980
|
+
device = torch.device('cuda', torch.cuda.current_device())
|
|
1981
|
+
torch.cuda.set_per_process_memory_fraction(fraction, device)
|
|
1982
|
+
torch.cuda.empty_cache()
|
|
1983
|
+
gpu = torch.cuda.get_device_properties(device)
|
|
1984
|
+
total = gpu.total_memory / 2**30
|
|
1985
|
+
print(f'Device: {device}')
|
|
1986
|
+
print(f' GPU: {gpu.name}')
|
|
1987
|
+
print(f'Memory: {total:.2f} GiB')
|
|
1988
|
+
print(f' Cap: {fraction * total:.2f} GiB ({fraction:.0%})')
|
|
1989
|
+
return device
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
def model_freeze(model, *parts):
|
|
1993
|
+
"""
|
|
1994
|
+
Freezes a network, or named parts of it, so their weights do not train; prints what trains after.
|
|
1995
|
+
|
|
1996
|
+
Args:
|
|
1997
|
+
model: a PyTorch network: a mcode-neural network Model, e.g. ClassificationModel, or an
|
|
1998
|
+
external network, e.g. torchvision's VGG11.
|
|
1999
|
+
parts: submodule paths as the code reaches them, e.g. "features" or "classifier[6]"
|
|
2000
|
+
("classifier.6" reads the same); none freezes the whole network.
|
|
2001
|
+
|
|
2002
|
+
Note:
|
|
2003
|
+
Every parameter of each part is set not to train ('requires_grad' False); an optimizer
|
|
2004
|
+
built over all of 'model.parameters()' then steps only the ones that train. A Model
|
|
2005
|
+
records a "freeze" event, {"parts"} and {"trainable", "total"}, which 'model_load()'
|
|
2006
|
+
replays. 'show_model_trace()' and 'show_model_internals()' mark what trains.
|
|
2007
|
+
|
|
2008
|
+
Example:
|
|
2009
|
+
>>> model_freeze(vgg11)
|
|
2010
|
+
Froze the whole network: 0 of 132,863,336 parameters train
|
|
2011
|
+
"""
|
|
2012
|
+
trainable, total = _set_trainable(model, parts, False)
|
|
2013
|
+
named = [_path_text(path) for path in parts]
|
|
2014
|
+
what = ', '.join(named) if named else 'the whole network'
|
|
2015
|
+
print(f'Froze {what}: {trainable:,} of {total:,} parameters train')
|
|
2016
|
+
if isinstance(model, Model):
|
|
2017
|
+
model._record(
|
|
2018
|
+
'freeze', {'parts': named}, {'trainable': trainable, 'total': total}
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def model_unfreeze(model, *parts):
|
|
2023
|
+
"""
|
|
2024
|
+
Unfreezes a network, or named parts of it, so their weights train again; prints what trains after.
|
|
2025
|
+
|
|
2026
|
+
Args:
|
|
2027
|
+
model: a PyTorch network, a mcode-neural network Model or an external network.
|
|
2028
|
+
parts: submodule paths, e.g. "classifier"; none unfreezes the whole network.
|
|
2029
|
+
|
|
2030
|
+
Note:
|
|
2031
|
+
A Model records an "unfreeze" event, as 'model_freeze()' records "freeze".
|
|
2032
|
+
|
|
2033
|
+
Example:
|
|
2034
|
+
>>> model_unfreeze(vgg11)
|
|
2035
|
+
Unfroze the whole network: 128,942,507 of 128,942,507 parameters train
|
|
2036
|
+
"""
|
|
2037
|
+
trainable, total = _set_trainable(model, parts, True)
|
|
2038
|
+
named = [_path_text(path) for path in parts]
|
|
2039
|
+
what = ', '.join(named) if named else 'the whole network'
|
|
2040
|
+
print(f'Unfroze {what}: {trainable:,} of {total:,} parameters train')
|
|
2041
|
+
if isinstance(model, Model):
|
|
2042
|
+
model._record(
|
|
2043
|
+
'unfreeze', {'parts': named}, {'trainable': trainable, 'total': total}
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def model_replace_output(model, out_features):
|
|
2048
|
+
"""
|
|
2049
|
+
Replaces a network's output layer, its last nn.Linear, with a new one of 'out_features' outputs.
|
|
2050
|
+
|
|
2051
|
+
Args:
|
|
2052
|
+
model: a PyTorch network, a mcode-neural network Model or an external network.
|
|
2053
|
+
out_features: the new output layer's outputs, e.g. 43 classes; a whole number of at least 1.
|
|
2054
|
+
|
|
2055
|
+
Note:
|
|
2056
|
+
The output layer is the network's last nn.Linear in module order, e.g. VGG's
|
|
2057
|
+
"classifier[6]" or ResNet's "fc"; its path is printed. The new layer keeps its
|
|
2058
|
+
'in_features', bias, device, and dtype, and trains; its weights are new. A Model's Network
|
|
2059
|
+
and Layer views read the new layer, and it records a "replace_output" event,
|
|
2060
|
+
{"out_features"} and {"path", "in_features"}, which 'model_load()' replays before
|
|
2061
|
+
loading the saved weights, so a saved swapped Model loads again.
|
|
2062
|
+
|
|
2063
|
+
Example:
|
|
2064
|
+
>>> model_replace_output(vgg11, 43)
|
|
2065
|
+
Replaced classifier[6]: Linear(4096 -> 1000) with Linear(4096 -> 43)
|
|
2066
|
+
"""
|
|
2067
|
+
_parts(model, ())
|
|
2068
|
+
if (
|
|
2069
|
+
isinstance(out_features, bool)
|
|
2070
|
+
or not isinstance(out_features, numbers.Integral)
|
|
2071
|
+
or out_features < 1
|
|
2072
|
+
):
|
|
2073
|
+
raise ValueError(
|
|
2074
|
+
f"'out_features' must be a whole number of at least 1, got {out_features!r}"
|
|
2075
|
+
)
|
|
2076
|
+
path, old, new = _replace_output(model, out_features)
|
|
2077
|
+
path = _path_text(path)
|
|
2078
|
+
print(
|
|
2079
|
+
f'Replaced {path}: Linear({old.in_features} -> {old.out_features})'
|
|
2080
|
+
f' with Linear({new.in_features} -> {new.out_features})'
|
|
2081
|
+
)
|
|
2082
|
+
if isinstance(model, Model):
|
|
2083
|
+
model._record(
|
|
2084
|
+
'replace_output',
|
|
2085
|
+
{'out_features': int(out_features)},
|
|
2086
|
+
{'path': path, 'in_features': new.in_features},
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
# endregion
|
|
2091
|
+
# endregion
|
|
2092
|
+
# endregion
|