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/data.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
# region F I L E
|
|
2
|
+
# <copyright file="mcode-neural/src/mcode_neural/data.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.data'
|
|
9
|
+
@memberof mcode_neural
|
|
10
|
+
@created October 2026
|
|
11
|
+
@author Timothy McGuire, MicroCODE, Inc.
|
|
12
|
+
@description >
|
|
13
|
+
MicroCODE Shared Dataset Library
|
|
14
|
+
|
|
15
|
+
LICENSE:
|
|
16
|
+
--------
|
|
17
|
+
MIT License: MicroCODE.mcode-neural
|
|
18
|
+
|
|
19
|
+
Copyright (c) 2026 Timothy McGuire, MicroCODE, Inc.
|
|
20
|
+
|
|
21
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
22
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
23
|
+
in the Software without restriction, including without limitation the rights
|
|
24
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
25
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
26
|
+
furnished to do so, subject to the following conditions:
|
|
27
|
+
|
|
28
|
+
The above copyright notice and this permission notice shall be included in all
|
|
29
|
+
copies or substantial portions of the Software.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DESCRIPTION:
|
|
41
|
+
------------
|
|
42
|
+
This module implements MicroCODE's common Python functions for preparing
|
|
43
|
+
PyTorch datasets for training: reading every label of a dataset without
|
|
44
|
+
loading its inputs; splitting a dataset into training and
|
|
45
|
+
validation subsets, optionally stratified by class; splitting a PyTorch
|
|
46
|
+
training dataset beside its test dataset into a model's train, val, and test
|
|
47
|
+
data; splitting a pandas DataFrame into a model's standardized train, val, and
|
|
48
|
+
test data; scaling new
|
|
49
|
+
DataFrame rows the same way, as another phase; one DataLoader per phase; and
|
|
50
|
+
a classifier's 'classes', a color and a name per label, for any number of classes.
|
|
51
|
+
Each split returns a 'Data', of a kind in 'DATA_KINDS', which a model reads.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
REFERENCES:
|
|
55
|
+
-----------
|
|
56
|
+
1. MIT xPRO Course: AI and Deep Learning: Neural Network Applications
|
|
57
|
+
|
|
58
|
+
2. PyTorch -- torch.utils.data (Dataset, Subset, random_split, TensorDataset, DataLoader)
|
|
59
|
+
https://pytorch.org/docs/stable/data.html
|
|
60
|
+
|
|
61
|
+
3. pandas -- DataFrame.sample
|
|
62
|
+
https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.sample.html
|
|
63
|
+
|
|
64
|
+
4. Python -- dataclasses
|
|
65
|
+
https://docs.python.org/3/library/dataclasses.html
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# endregion
|
|
69
|
+
|
|
70
|
+
# region I M P O R T S
|
|
71
|
+
|
|
72
|
+
import math
|
|
73
|
+
|
|
74
|
+
from dataclasses import dataclass
|
|
75
|
+
|
|
76
|
+
import colorsys
|
|
77
|
+
import numbers
|
|
78
|
+
|
|
79
|
+
import pandas as pd
|
|
80
|
+
import torch
|
|
81
|
+
from torch.utils.data import DataLoader, Subset, TensorDataset
|
|
82
|
+
|
|
83
|
+
# endregion
|
|
84
|
+
|
|
85
|
+
# region C O N S T A N T S
|
|
86
|
+
|
|
87
|
+
# the phases 'data_frame_split()' makes, in the order a trainer runs them
|
|
88
|
+
SPLIT_PHASES = ('train', 'val', 'test')
|
|
89
|
+
LABEL_ATTRIBUTES = { # where a dataset keeps its labels, in lookup order, each read without loading an input
|
|
90
|
+
'targets': lambda held: held, # MNIST, CIFAR, ImageFolder
|
|
91
|
+
'labels': lambda held: held, # SVHN, STL10
|
|
92
|
+
'_labels': lambda held: held, # Flowers102, Food101, DTD, OxfordIIITPet
|
|
93
|
+
'y': lambda held: held, # Caltech101
|
|
94
|
+
# GTSRB, StanfordCars: (path, label) pairs
|
|
95
|
+
'_samples': lambda held: [label for _, label in held],
|
|
96
|
+
}
|
|
97
|
+
# what all data of one kind holds, keyed by where it came from:
|
|
98
|
+
# has_features: it names its feature columns, 'feature', which label a neuron's weights
|
|
99
|
+
# has_scaling: it keeps a DataFrame contract, 'frame', so 'data_frame_phase()' can add rows
|
|
100
|
+
DATA_KINDS = {
|
|
101
|
+
'frame': {'has_features': True, 'has_scaling': True},
|
|
102
|
+
'dataset': {'has_features': False, 'has_scaling': False},
|
|
103
|
+
}
|
|
104
|
+
# the colors of 'data_classes()': up to 10 classes, matplotlib's cycle, so every existing chart is unchanged
|
|
105
|
+
CLASS_COLORS = tuple(f'C{k}' for k in range(10))
|
|
106
|
+
# past 10, label k's hue is k x the golden ratio's conjugate around the wheel, so neighbors are never near in hue
|
|
107
|
+
CLASS_HUE_STEP = 0.618033988749895
|
|
108
|
+
CLASS_LIGHTNESS = (
|
|
109
|
+
0.62,
|
|
110
|
+
0.74,
|
|
111
|
+
) # alternating by label; both read on the dark chart background
|
|
112
|
+
CLASS_SATURATION = 0.75
|
|
113
|
+
|
|
114
|
+
# endregion
|
|
115
|
+
|
|
116
|
+
# region C L A S S E S
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Data:
|
|
121
|
+
"""
|
|
122
|
+
The data a model reads: its datasets by phase, and the contract that names and scales them.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
kind: where the data came from, a key of 'DATA_KINDS': "frame" for rows split from a
|
|
126
|
+
DataFrame by 'data_frame_split()', "dataset" for PyTorch datasets, e.g. images.
|
|
127
|
+
datasets: {phase: dataset}, e.g. {"train": ..., "val": ..., "test": ...}; each item is
|
|
128
|
+
(features, labels), or (features,) for a phase with no labels.
|
|
129
|
+
feature: one name per feature, in the model's column order, for "frame" data; None for
|
|
130
|
+
"dataset" data, which has no named columns.
|
|
131
|
+
classes: for a classifier, {label: (color, name)} for every label; None for a regressor;
|
|
132
|
+
either for an autoencoder, whose latent view they color.
|
|
133
|
+
frame: for "frame" data, the DataFrame contract 'data_frame_phase()' scales new rows by,
|
|
134
|
+
{"label_column": a name or a list of names, "label_dtype": a torch dtype,
|
|
135
|
+
"scaling": {column: (mean, std)}}; None for "dataset" data.
|
|
136
|
+
split: how the datasets were split, the split function and its settings, e.g.
|
|
137
|
+
{"function": "data_frame_split", "train_split": 0.6, ..., "seed": 42}, which the
|
|
138
|
+
model's "data" event records; None when not recorded, e.g. data built by hand.
|
|
139
|
+
|
|
140
|
+
Note:
|
|
141
|
+
Frozen, so the contract is never replaced piece by piece; 'datasets' is a dict, so
|
|
142
|
+
'data_frame_phase()' can add a phase to it. A 'feature' or 'frame' its kind does not
|
|
143
|
+
hold, or lacks, fails by name.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> model.data = data_frame_split(df, input_columns, "color", torch.int64,
|
|
147
|
+
... input_columns, wine_classes, seed=42)
|
|
148
|
+
>>> X, Y = model.data.samples("test")
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
kind: str
|
|
152
|
+
datasets: dict
|
|
153
|
+
feature: list | None
|
|
154
|
+
classes: dict | None
|
|
155
|
+
frame: dict | None
|
|
156
|
+
split: dict | None
|
|
157
|
+
|
|
158
|
+
def __post_init__(self):
|
|
159
|
+
if self.kind not in DATA_KINDS:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"'Data' kind must be one of {list(DATA_KINDS)}, got {self.kind!r}"
|
|
162
|
+
)
|
|
163
|
+
row = DATA_KINDS[self.kind]
|
|
164
|
+
if (self.feature is not None) != row['has_features']:
|
|
165
|
+
needs = (
|
|
166
|
+
'names its features' if row['has_features'] else 'has no named features'
|
|
167
|
+
)
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"'Data' kind {self.kind!r} {needs}; got feature={self.feature!r}"
|
|
170
|
+
)
|
|
171
|
+
if (self.frame is not None) != row['has_scaling']:
|
|
172
|
+
needs = (
|
|
173
|
+
'keeps a DataFrame contract'
|
|
174
|
+
if row['has_scaling']
|
|
175
|
+
else 'has no DataFrame contract'
|
|
176
|
+
)
|
|
177
|
+
got = 'frame=None' if self.frame is None else 'a frame'
|
|
178
|
+
raise ValueError(f"'Data' kind {self.kind!r} {needs}; got {got}")
|
|
179
|
+
|
|
180
|
+
def samples(self, phase):
|
|
181
|
+
"""
|
|
182
|
+
Returns every sample of one phase in one batch, as NumPy arrays.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
phase: the phase's name, a key of 'datasets', e.g. "test".
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
(X, Y): X of shape (N, ...), one sample per row; Y of shape (N,) or (N, k), or None
|
|
189
|
+
for a phase with no labels.
|
|
190
|
+
"""
|
|
191
|
+
dataset = self._dataset(phase)
|
|
192
|
+
# one batch of the whole phase: the default collate stacks any dataset's items, rows or images
|
|
193
|
+
batch = next(iter(DataLoader(dataset, batch_size=len(dataset))))
|
|
194
|
+
labels = batch[1].numpy() if len(batch) == 2 else None
|
|
195
|
+
return batch[0].numpy(), labels
|
|
196
|
+
|
|
197
|
+
def _dataset(self, phase):
|
|
198
|
+
"""
|
|
199
|
+
Returns one phase's dataset, failing by name when the phase is missing or holds no samples.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
phase: the phase's name, a key of 'datasets', e.g. "test".
|
|
203
|
+
"""
|
|
204
|
+
if phase not in self.datasets:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"'model.data' has no phase {phase!r}; its phases are {list(self.datasets)}"
|
|
207
|
+
)
|
|
208
|
+
dataset = self.datasets[phase]
|
|
209
|
+
if len(dataset) == 0:
|
|
210
|
+
raise ValueError(f"'model.data' phase {phase!r} holds no samples")
|
|
211
|
+
return dataset
|
|
212
|
+
|
|
213
|
+
def _is_labeled(self, phase):
|
|
214
|
+
"""Returns whether one phase's items hold labels, (features, labels), read from its first item."""
|
|
215
|
+
return len(self._dataset(phase)[0]) == 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# endregion
|
|
219
|
+
|
|
220
|
+
# region P R I V A T E F U N C T I O N S
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _held_labels(dataset):
|
|
224
|
+
"""
|
|
225
|
+
Returns the labels a dataset holds, read without loading an input, or None when it holds none.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
dataset: any PyTorch dataset of (input, label) items.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
the labels as one tensor, one per datapoint, in the dataset's order: a Subset's are its
|
|
232
|
+
parent's at its indices, down to the root dataset; any other dataset's are its first
|
|
233
|
+
'LABEL_ATTRIBUTES' entry, through its 'target_transform' when it has one. None when
|
|
234
|
+
the dataset, or a Subset's root, holds none of them.
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(dataset, Subset):
|
|
237
|
+
labels = _held_labels(dataset.dataset)
|
|
238
|
+
if labels is None:
|
|
239
|
+
return None
|
|
240
|
+
return labels[torch.as_tensor(dataset.indices, dtype=torch.long)]
|
|
241
|
+
for attribute, read in LABEL_ATTRIBUTES.items():
|
|
242
|
+
held = getattr(dataset, attribute, None)
|
|
243
|
+
if held is not None:
|
|
244
|
+
labels = read(held)
|
|
245
|
+
# a dataset serves each label through its target_transform, one Python scalar at a time
|
|
246
|
+
transform = getattr(dataset, 'target_transform', None)
|
|
247
|
+
if transform is not None:
|
|
248
|
+
labels = [
|
|
249
|
+
transform(label) for label in torch.as_tensor(labels).tolist()
|
|
250
|
+
]
|
|
251
|
+
return torch.as_tensor(labels)
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _check_columns(df, name, columns):
|
|
256
|
+
"""
|
|
257
|
+
Raises a ValueError naming 'name' unless every column is in 'df', numeric, and complete.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
df: the pandas DataFrame.
|
|
261
|
+
name: the argument that named the columns, e.g. "feature_columns".
|
|
262
|
+
columns: the column names to check.
|
|
263
|
+
"""
|
|
264
|
+
for column in columns:
|
|
265
|
+
if column not in df.columns:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"'{name}' names {column!r}, which is not a column of 'df'"
|
|
268
|
+
)
|
|
269
|
+
if not pd.api.types.is_numeric_dtype(df[column]):
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"'{name}' column {column!r} is {df[column].dtype}, not numeric; encode it first, e.g. one-hot"
|
|
272
|
+
)
|
|
273
|
+
if df[column].isna().any():
|
|
274
|
+
raise ValueError(f"'{name}' column {column!r} holds missing values (NaN)")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check_labels(df, label_columns, label_dtype, classes):
|
|
278
|
+
"""
|
|
279
|
+
Raises a ValueError naming the label column unless its labels fit 'label_dtype' and 'classes'.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
df: the pandas DataFrame, its label columns already checked by '_check_columns()'.
|
|
283
|
+
label_columns: the label column names.
|
|
284
|
+
label_dtype: the labels' torch dtype; an integer dtype would truncate fractions.
|
|
285
|
+
classes: {label: (color, name)} that must name every label, or None for a regressor.
|
|
286
|
+
"""
|
|
287
|
+
for column in label_columns:
|
|
288
|
+
if not label_dtype.is_floating_point and (df[column] % 1 != 0).any():
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"'label_column' {column!r} holds fractions, which 'label_dtype' {label_dtype} would truncate"
|
|
291
|
+
)
|
|
292
|
+
if classes is not None:
|
|
293
|
+
unknown = sorted(set(df[column].tolist()) - set(classes))
|
|
294
|
+
if unknown:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"'label_column' {column!r} holds {unknown}, not 'classes' labels {sorted(classes)}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _class_color(label, count):
|
|
301
|
+
"""
|
|
302
|
+
Returns a class's color: matplotlib's 'C0'-'C9' for up to 10 classes, else a golden-angle hex color.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
label: the class's label, 0 .. count - 1.
|
|
306
|
+
count: the number of classes.
|
|
307
|
+
"""
|
|
308
|
+
if count <= len(CLASS_COLORS):
|
|
309
|
+
return CLASS_COLORS[label]
|
|
310
|
+
hue = (label * CLASS_HUE_STEP) % 1
|
|
311
|
+
lightness = CLASS_LIGHTNESS[label % len(CLASS_LIGHTNESS)]
|
|
312
|
+
red, green, blue = colorsys.hls_to_rgb(hue, lightness, CLASS_SATURATION)
|
|
313
|
+
return f'#{round(red * 255):02x}{round(green * 255):02x}{round(blue * 255):02x}'
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# endregion
|
|
317
|
+
|
|
318
|
+
# region P U B L I C F U N C T I O N S
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def data_labels(dataset):
|
|
322
|
+
"""
|
|
323
|
+
Returns every label of a PyTorch dataset of (input, label) items, as one tensor, in the dataset's order.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
dataset: any PyTorch dataset of (input, label) items, e.g. a torchvision.datasets.GTSRB, or a
|
|
327
|
+
torch.utils.data.Subset of one, as 'data_subset_split()' returns.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
the labels, one per datapoint, as the dataset serves them.
|
|
331
|
+
|
|
332
|
+
Note:
|
|
333
|
+
The labels are read where the dataset keeps them, so no input is loaded or transformed:
|
|
334
|
+
'targets' (MNIST, CIFAR, ImageFolder), 'labels' (SVHN, STL10), '_labels' (Flowers102,
|
|
335
|
+
Food101), 'y' (Caltech101), or the labels of '_samples' (GTSRB), each through the
|
|
336
|
+
dataset's 'target_transform' when it has one. A Subset reads its parent's labels at its
|
|
337
|
+
indices, down to the root dataset. Only a dataset that keeps no labels, or a Subset of
|
|
338
|
+
one, is read item by item, every input loaded.
|
|
339
|
+
|
|
340
|
+
Example:
|
|
341
|
+
>>> labels = data_labels(gtsrb_train)
|
|
342
|
+
>>> labels.bincount() # the datapoints of each class
|
|
343
|
+
"""
|
|
344
|
+
labels = _held_labels(dataset)
|
|
345
|
+
if labels is None:
|
|
346
|
+
labels = torch.as_tensor([label for _, label in dataset])
|
|
347
|
+
return labels
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def data_subset_split(dataset, validate_split, seed=None, stratify=False):
|
|
351
|
+
"""
|
|
352
|
+
Splits a dataset randomly into a training subset and a validation subset.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
dataset: any PyTorch dataset with a length, e.g. a torchvision.datasets.MNIST.
|
|
356
|
+
validate_split: the fraction of 'dataset' held out for validation, between 0 and 1, e.g. 1/6,
|
|
357
|
+
as in Keras' 'validation_split'.
|
|
358
|
+
seed: seeds the shuffle, so every run holds out the same datapoints; None for a new split each run.
|
|
359
|
+
stratify: hold out 'validate_split' of each class, so both subsets keep the class proportions of
|
|
360
|
+
'dataset'; the labels are read by 'data_labels()'.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
(train, validate), two torch.utils.data.Subset views of 'dataset'; no datapoints are copied.
|
|
364
|
+
|
|
365
|
+
Note:
|
|
366
|
+
Stratifying rounds each class's share separately, so the validation subset can differ from
|
|
367
|
+
'validate_split' of 'dataset' by up to half a datapoint per class.
|
|
368
|
+
|
|
369
|
+
Example:
|
|
370
|
+
>>> train, validate = data_subset_split(mnist_train, validate_split=1/6, seed=0, stratify=True)
|
|
371
|
+
"""
|
|
372
|
+
if not 0 < validate_split < 1:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"'validate_split' must be between 0 and 1, not {validate_split}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# S E E D: one generator drives every shuffle below, so one seed repeats the whole split
|
|
378
|
+
# an unseeded torch.Generator() starts from the same fixed seed every run
|
|
379
|
+
generator = torch.Generator()
|
|
380
|
+
if seed is None:
|
|
381
|
+
generator.seed()
|
|
382
|
+
else:
|
|
383
|
+
generator.manual_seed(seed)
|
|
384
|
+
|
|
385
|
+
# R A N D O M S P L I T: without 'stratify', one shuffle of the whole dataset
|
|
386
|
+
if not stratify:
|
|
387
|
+
train, validate = torch.utils.data.random_split(
|
|
388
|
+
dataset, [1 - validate_split, validate_split], generator=generator
|
|
389
|
+
)
|
|
390
|
+
return train, validate
|
|
391
|
+
|
|
392
|
+
# S T R A T I F I E D S P L I T: hold out 'validate_split' of each class, class by class
|
|
393
|
+
labels = data_labels(dataset)
|
|
394
|
+
|
|
395
|
+
train_indices, validate_indices = [], []
|
|
396
|
+
for label in labels.unique():
|
|
397
|
+
indices = torch.nonzero(labels == label).flatten()
|
|
398
|
+
indices = indices[torch.randperm(len(indices), generator=generator)]
|
|
399
|
+
validate_count = round(len(indices) * validate_split)
|
|
400
|
+
validate_indices.append(indices[:validate_count])
|
|
401
|
+
train_indices.append(indices[validate_count:])
|
|
402
|
+
|
|
403
|
+
# the indices were gathered class by class, so an unshuffled DataLoader would serve one digit at a time
|
|
404
|
+
train_indices = torch.cat(train_indices)
|
|
405
|
+
validate_indices = torch.cat(validate_indices)
|
|
406
|
+
train_indices = train_indices[
|
|
407
|
+
torch.randperm(len(train_indices), generator=generator)
|
|
408
|
+
]
|
|
409
|
+
validate_indices = validate_indices[
|
|
410
|
+
torch.randperm(len(validate_indices), generator=generator)
|
|
411
|
+
]
|
|
412
|
+
return Subset(dataset, train_indices.tolist()), Subset(
|
|
413
|
+
dataset, validate_indices.tolist()
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def data_dataset_split(train, test, validate_split, classes, seed=None, stratify=False):
|
|
418
|
+
"""
|
|
419
|
+
Splits a PyTorch training dataset into a model's train and val data, beside its own test dataset.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
train: the training dataset of (input, label) items, e.g. torchvision.datasets.MNIST(train=True).
|
|
423
|
+
test: the test dataset of (input, label) items, e.g. torchvision.datasets.MNIST(train=False).
|
|
424
|
+
validate_split: the fraction of 'train' held out for validation, between 0 and 1, e.g. 1/6,
|
|
425
|
+
as in Keras' 'validation_split'.
|
|
426
|
+
classes: for a classifier, {label: (color, name)} naming every label in 'train' and 'test',
|
|
427
|
+
e.g. {0: ("C0", "0 - zero"), ...}; None for a regressor; either for an
|
|
428
|
+
autoencoder, whose latent view they color.
|
|
429
|
+
seed: seeds the split, so every run holds out the same datapoints; None for a new split each run.
|
|
430
|
+
stratify: hold out 'validate_split' of each class, so train and val keep the class proportions
|
|
431
|
+
of 'train'.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
a "dataset" Data to set as 'model.data': its 'datasets' are "train" and "val", the two
|
|
435
|
+
torch.utils.data.Subset views of 'data_subset_split()', and "test", 'test' itself; its
|
|
436
|
+
'classes' are 'classes', and its 'feature' and 'frame' are None, as a dataset of images
|
|
437
|
+
has no named columns and no DataFrame scaling. Its 'split' is {"function":
|
|
438
|
+
"data_dataset_split", "validate_split", "seed", "stratify"}, for the model's history.
|
|
439
|
+
|
|
440
|
+
Note:
|
|
441
|
+
Each dataset keeps its own transform, e.g. ToTensor() and Normalize(); nothing is copied or
|
|
442
|
+
scaled here. Each dataset's labels are read by 'data_labels()'.
|
|
443
|
+
|
|
444
|
+
Example:
|
|
445
|
+
>>> mnist_train = torchvision.datasets.MNIST(data_dir, train=True, transform=transform)
|
|
446
|
+
>>> mnist_test = torchvision.datasets.MNIST(data_dir, train=False, transform=transform)
|
|
447
|
+
>>> digit_classes = {label: (f"C{label}", name) for label, name in enumerate(mnist_test.classes)}
|
|
448
|
+
>>> model.data = data_dataset_split(mnist_train, mnist_test, 1/6, digit_classes,
|
|
449
|
+
... seed=0, stratify=True)
|
|
450
|
+
"""
|
|
451
|
+
# V A L I D A T E: the classes must name every label, so a stray label fails by name before training
|
|
452
|
+
if classes is not None:
|
|
453
|
+
if not isinstance(classes, dict):
|
|
454
|
+
raise TypeError(
|
|
455
|
+
f"'classes' must be {{label: (color, name)}}, or None for a regressor, not {classes!r}"
|
|
456
|
+
)
|
|
457
|
+
for name, dataset in {'train': train, 'test': test}.items():
|
|
458
|
+
unknown = sorted(set(data_labels(dataset).unique().tolist()) - set(classes))
|
|
459
|
+
if unknown:
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"'{name}' holds labels {unknown}, not 'classes' labels {sorted(classes)}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# S P L I T: hold out the val datapoints from 'train'; 'test' is already its own dataset
|
|
465
|
+
train_subset, val_subset = data_subset_split(
|
|
466
|
+
train, validate_split, seed=seed, stratify=stratify
|
|
467
|
+
)
|
|
468
|
+
datasets = dict(zip(SPLIT_PHASES, (train_subset, val_subset, test)))
|
|
469
|
+
split = {
|
|
470
|
+
'function': 'data_dataset_split',
|
|
471
|
+
'validate_split': validate_split,
|
|
472
|
+
'seed': seed,
|
|
473
|
+
'stratify': stratify,
|
|
474
|
+
}
|
|
475
|
+
return Data('dataset', datasets, None, classes, None, split)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def data_frame_split(
|
|
479
|
+
df,
|
|
480
|
+
feature_columns,
|
|
481
|
+
label_column,
|
|
482
|
+
label_dtype,
|
|
483
|
+
standardize_columns,
|
|
484
|
+
classes,
|
|
485
|
+
train_split=0.6,
|
|
486
|
+
validate_split=0.2,
|
|
487
|
+
test_split=0.2,
|
|
488
|
+
seed=None,
|
|
489
|
+
):
|
|
490
|
+
"""
|
|
491
|
+
Splits a DataFrame into a model's train, val, and test data, standardized from the train rows only.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
df: the pandas DataFrame, one row per datapoint, with a unique index.
|
|
495
|
+
feature_columns: the feature column names, in the order the model reads them.
|
|
496
|
+
label_column: the name of the label column; or a list of names, one label column each, e.g.
|
|
497
|
+
the yes / no columns of a multi-label model.
|
|
498
|
+
label_dtype: the labels' torch dtype: torch.int64 for class labels, as 'nn.CrossEntropyLoss'
|
|
499
|
+
needs ('train_model()' casts 0 / 1 labels for 'nn.BCEWithLogitsLoss'), or
|
|
500
|
+
torch.float32 for regression targets, as 'nn.MSELoss' needs.
|
|
501
|
+
standardize_columns: the features to standardize, e.g. the numeric ones but not the one-hot
|
|
502
|
+
ones; [] standardizes none. The other features pass through unscaled.
|
|
503
|
+
classes: for a classifier, {label: (color, name)} naming every label in the label column(s),
|
|
504
|
+
e.g. {0: ("tab:blue", "young"), 1: ("tab:orange", "old")}; None for a regressor;
|
|
505
|
+
either for an autoencoder, whose latent view they color.
|
|
506
|
+
train_split: the fraction of 'df' to train on.
|
|
507
|
+
validate_split: the fraction of 'df' to validate on.
|
|
508
|
+
test_split: the fraction of 'df' to test on; the three fractions sum to 1.
|
|
509
|
+
seed: seeds both samples, so every run splits the same rows; None for a new split each run.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
a "frame" Data to set as 'model.data': its 'datasets' hold a torch.utils.data.TensorDataset
|
|
513
|
+
per phase, "train", "val", and "test", of (features, labels): features
|
|
514
|
+
(N, len(feature_columns)) float32, labels (N,) 'label_dtype', or (N, len(label_column))
|
|
515
|
+
when 'label_column' is a list, (N, 1) for a list of one name. Its 'feature' is
|
|
516
|
+
'feature_columns', its 'classes' are 'classes', and its 'frame' keeps the label column(s),
|
|
517
|
+
'label_dtype', and each standardize column's (mean, std), for 'data_frame_phase()'. Its
|
|
518
|
+
'split' is {"function": "data_frame_split", "train_split", "validate_split",
|
|
519
|
+
"test_split", "seed"}, for the model's history.
|
|
520
|
+
|
|
521
|
+
Note:
|
|
522
|
+
Each standardize column becomes (x - mean) / std, with the mean and the population std
|
|
523
|
+
(ddof=0, as scikit-learn's 'StandardScaler') of the train rows only, so no val or test value
|
|
524
|
+
leaks into training. As in the course, 'test_split' of 'df' is sampled out first, then
|
|
525
|
+
'validate_split' of 'df' from the rest; seed=42 reproduces the course's rows in its order.
|
|
526
|
+
|
|
527
|
+
Example:
|
|
528
|
+
>>> model.data = data_frame_split(encoded_df, encoded_feature_columns, "Old", torch.int64,
|
|
529
|
+
... numerical_feature_columns, abalone_classes, seed=42)
|
|
530
|
+
"""
|
|
531
|
+
# V A L I D A T E: every column, dtype, and fraction fails by name before any row is sampled
|
|
532
|
+
feature_columns = list(feature_columns)
|
|
533
|
+
standardize_columns = list(standardize_columns)
|
|
534
|
+
is_label_list = isinstance(label_column, (list, tuple))
|
|
535
|
+
label_columns = list(label_column) if is_label_list else [label_column]
|
|
536
|
+
if not feature_columns:
|
|
537
|
+
raise ValueError(
|
|
538
|
+
"'feature_columns' is empty, so there are no features to split"
|
|
539
|
+
)
|
|
540
|
+
if not label_columns:
|
|
541
|
+
raise ValueError("'label_column' is empty, so there are no labels to split")
|
|
542
|
+
_check_columns(df, 'feature_columns', feature_columns)
|
|
543
|
+
_check_columns(df, 'label_column', label_columns)
|
|
544
|
+
for column in label_columns:
|
|
545
|
+
if column in feature_columns:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
f"'label_column' {column!r} is also in 'feature_columns', so the model would read its own answer"
|
|
548
|
+
)
|
|
549
|
+
for column in standardize_columns:
|
|
550
|
+
if column not in feature_columns:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"'standardize_columns' names {column!r}, which is not in 'feature_columns'"
|
|
553
|
+
)
|
|
554
|
+
if not isinstance(label_dtype, torch.dtype):
|
|
555
|
+
raise TypeError(
|
|
556
|
+
f"'label_dtype' must be a torch dtype, e.g. torch.int64 or torch.float32, not {label_dtype!r}"
|
|
557
|
+
)
|
|
558
|
+
if classes is not None and not isinstance(classes, dict):
|
|
559
|
+
raise TypeError(
|
|
560
|
+
f"'classes' must be {{label: (color, name)}}, or None for a regressor, not {classes!r}"
|
|
561
|
+
)
|
|
562
|
+
_check_labels(df, label_columns, label_dtype, classes)
|
|
563
|
+
fractions = {
|
|
564
|
+
'train_split': train_split,
|
|
565
|
+
'validate_split': validate_split,
|
|
566
|
+
'test_split': test_split,
|
|
567
|
+
}
|
|
568
|
+
for name, fraction in fractions.items():
|
|
569
|
+
if not 0 < fraction < 1:
|
|
570
|
+
raise ValueError(f"'{name}' must be between 0 and 1, not {fraction}")
|
|
571
|
+
total = sum(fractions.values())
|
|
572
|
+
if not math.isclose(total, 1):
|
|
573
|
+
raise ValueError(
|
|
574
|
+
f"'train_split' + 'validate_split' + 'test_split' must be 1, not {total}"
|
|
575
|
+
)
|
|
576
|
+
if not df.index.is_unique:
|
|
577
|
+
raise ValueError(
|
|
578
|
+
"'df' repeats an index label, so its rows cannot be told apart; call 'df.reset_index(drop=True)' first"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# S P L I T: sample the test rows out first, then the val rows from the rest, as the course does
|
|
582
|
+
# the course's two stages; 1 - 0.2 / 0.8 is exactly its 0.75, where 0.6 / 0.8 is 0.7499999999999999
|
|
583
|
+
train_val = df.sample(frac=1 - test_split, random_state=seed)
|
|
584
|
+
train = train_val.sample(
|
|
585
|
+
frac=1 - validate_split / (1 - test_split), random_state=seed
|
|
586
|
+
)
|
|
587
|
+
splits = dict(
|
|
588
|
+
zip(
|
|
589
|
+
SPLIT_PHASES, (train, train_val.drop(train.index), df.drop(train_val.index))
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
for phase, rows in splits.items():
|
|
593
|
+
if rows.empty:
|
|
594
|
+
raise ValueError(
|
|
595
|
+
f"the '{phase}' split of the {len(df)} rows in 'df' is empty; 'df' needs more rows"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# S T A N D A R D I Z E: the mean and std come from the train rows only, so no val or test value leaks into training
|
|
599
|
+
mean = train[standardize_columns].mean()
|
|
600
|
+
std = train[standardize_columns].std(ddof=0)
|
|
601
|
+
for column in standardize_columns:
|
|
602
|
+
if std[column] == 0:
|
|
603
|
+
raise ValueError(
|
|
604
|
+
f"'standardize_columns' column {column!r} is constant in the train rows, so it cannot be standardized"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# D A T A S E T S: one TensorDataset of (features, labels) per phase, scaled by the train rows
|
|
608
|
+
# pandas' own rule: a name selects a column, (N,); a list selects a frame, (N, k)
|
|
609
|
+
label_key = label_columns if is_label_list else label_column
|
|
610
|
+
datasets = {}
|
|
611
|
+
for phase, rows in splits.items():
|
|
612
|
+
features = rows[feature_columns].astype('float64')
|
|
613
|
+
features[standardize_columns] = (features[standardize_columns] - mean) / std
|
|
614
|
+
datasets[phase] = TensorDataset(
|
|
615
|
+
torch.tensor(features.to_numpy(), dtype=torch.float32),
|
|
616
|
+
torch.tensor(rows[label_key].to_numpy(), dtype=label_dtype),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# C O N T R A C T: what new rows need to be read the same way; plain floats, so a model saves it
|
|
620
|
+
frame = {
|
|
621
|
+
'label_column': label_key,
|
|
622
|
+
'label_dtype': label_dtype,
|
|
623
|
+
'scaling': {
|
|
624
|
+
column: (float(mean[column]), float(std[column]))
|
|
625
|
+
for column in standardize_columns
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
# the scaled columns are the keys of 'frame["scaling"]', so they are not repeated here
|
|
629
|
+
split = {'function': 'data_frame_split', **fractions, 'seed': seed}
|
|
630
|
+
return Data('frame', datasets, feature_columns, classes, frame, split)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def data_frame_phase(model, phase, df):
|
|
634
|
+
"""
|
|
635
|
+
Adds new DataFrame rows to a model's data as one more phase, scaled as its training rows were.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
model: a model whose data came from 'data_frame_split()', or 'model_load()' of one.
|
|
639
|
+
phase: the new phase's name, e.g. "new"; not "train", "val", or "test", the split's own
|
|
640
|
+
rows. An existing phase of that name is replaced.
|
|
641
|
+
df: the raw, unscaled rows: a pandas DataFrame holding every one of the model's feature
|
|
642
|
+
columns; and either every label column, to score the rows, or none, to predict them.
|
|
643
|
+
|
|
644
|
+
Note:
|
|
645
|
+
Each feature is scaled by the train rows' (mean, std) that 'data_frame_split()' kept in
|
|
646
|
+
'model.data.frame', and read in the model's own column order; extra columns are ignored.
|
|
647
|
+
The datasets of 'model.data' gain the phase; the model's weights are not touched.
|
|
648
|
+
|
|
649
|
+
Example:
|
|
650
|
+
>>> data_frame_phase(model, "new", new_wines)
|
|
651
|
+
>>> columns, probabilities = predict_classes(model, "new")
|
|
652
|
+
"""
|
|
653
|
+
# V A L I D A T E: the model's contract, the phase name, and every column fail by name first
|
|
654
|
+
data = model.data
|
|
655
|
+
if not DATA_KINDS[data.kind]['has_scaling']:
|
|
656
|
+
raise ValueError(
|
|
657
|
+
"'model.data' did not come from 'data_frame_split()', so it has no scaling for new rows"
|
|
658
|
+
)
|
|
659
|
+
if phase in SPLIT_PHASES:
|
|
660
|
+
raise ValueError(
|
|
661
|
+
f"'phase' {phase!r} is one of the split's own phases {list(SPLIT_PHASES)}; name new rows another phase, e.g. 'new'"
|
|
662
|
+
)
|
|
663
|
+
if df.empty:
|
|
664
|
+
raise ValueError("'df' has no rows to add")
|
|
665
|
+
frame = data.frame
|
|
666
|
+
label_key = frame['label_column']
|
|
667
|
+
label_columns = label_key if isinstance(label_key, list) else [label_key]
|
|
668
|
+
_check_columns(df, 'feature_columns', data.feature)
|
|
669
|
+
present = [column for column in label_columns if column in df.columns]
|
|
670
|
+
# some labels but not all would score the rows against half an answer
|
|
671
|
+
if present and len(present) != len(label_columns):
|
|
672
|
+
missing = [column for column in label_columns if column not in present]
|
|
673
|
+
raise ValueError(
|
|
674
|
+
f"'df' holds label column(s) {present} but not {missing}; give every label column, or none"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# S C A L E: each standardize column by the train rows' (mean, std), the rest as they are
|
|
678
|
+
features = df[data.feature].astype('float64')
|
|
679
|
+
for column, (mean, std) in frame['scaling'].items():
|
|
680
|
+
features[column] = (features[column] - mean) / std
|
|
681
|
+
tensors = [torch.tensor(features.to_numpy(), dtype=torch.float32)]
|
|
682
|
+
if present:
|
|
683
|
+
_check_columns(df, 'label_column', label_columns)
|
|
684
|
+
_check_labels(df, label_columns, frame['label_dtype'], data.classes)
|
|
685
|
+
tensors.append(
|
|
686
|
+
torch.tensor(df[label_key].to_numpy(), dtype=frame['label_dtype'])
|
|
687
|
+
)
|
|
688
|
+
data.datasets[phase] = TensorDataset(*tensors)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def data_loaders(datasets, batch_size):
|
|
692
|
+
"""
|
|
693
|
+
Wraps each dataset in a DataLoader of 'batch_size' batches, keyed by the same phase names.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
datasets: {phase: dataset}, e.g. the train, val, and test datasets of 'data_frame_split()'.
|
|
697
|
+
batch_size: the number of datapoints in each batch.
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
{phase: torch.utils.data.DataLoader}, the same keys in the same order.
|
|
701
|
+
|
|
702
|
+
Note:
|
|
703
|
+
No loader shuffles, as in the course: every epoch serves the same batches in the same order,
|
|
704
|
+
and 'data_frame_split()' has already put the train rows in random order. A phase's size is
|
|
705
|
+
'len(loaders[phase].dataset)', so no separate 'dataset_sizes' dict is needed.
|
|
706
|
+
|
|
707
|
+
Example:
|
|
708
|
+
>>> loaders = data_loaders(datasets, batch_size=10)
|
|
709
|
+
"""
|
|
710
|
+
if not datasets:
|
|
711
|
+
raise ValueError("'datasets' is empty, so there are no loaders to make")
|
|
712
|
+
return {
|
|
713
|
+
phase: DataLoader(dataset, batch_size=batch_size)
|
|
714
|
+
for phase, dataset in datasets.items()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def data_classes(names):
|
|
719
|
+
"""
|
|
720
|
+
Returns a classifier's 'classes', {label: (color, name)}, for labels 0 .. n-1, each its own color.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
names: the class names in label order, a list or tuple of strings, e.g. 'dataset.classes';
|
|
724
|
+
or the number of classes, a whole number of at least 1, each then named by its label,
|
|
725
|
+
e.g. 43 for GTSRB, whose dataset names none.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
{0: (color, name), 1: (color, name), ...}, as 'data_frame_split()', 'data_dataset_split()',
|
|
729
|
+
and every classifier view take 'classes'.
|
|
730
|
+
|
|
731
|
+
Note:
|
|
732
|
+
Up to 10 classes take matplotlib's cycle, "C0" to "C9", as a hand-built dict did, so a
|
|
733
|
+
chart's colors are unchanged. Past 10, label k's hue is k times the golden ratio's
|
|
734
|
+
conjugate around the color wheel, so labels next to each other are never near in hue,
|
|
735
|
+
at two lightness levels, alternating, both readable on the charts' dark background;
|
|
736
|
+
each is a "#rrggbb" string, the same on every run, which 'model_save()' keeps.
|
|
737
|
+
|
|
738
|
+
Example:
|
|
739
|
+
>>> fashion_classes = data_classes(fashion_mnist_train.classes)
|
|
740
|
+
>>> gtsrb_classes = data_classes(43) # names "0" ... "42"
|
|
741
|
+
>>> gtsrb_classes = data_classes(sign_names) # 43 names, in label order
|
|
742
|
+
"""
|
|
743
|
+
# V A L I D A T E: a list of names or a count, each failing by name
|
|
744
|
+
if isinstance(names, bool) or not isinstance(
|
|
745
|
+
names, (list, tuple, numbers.Integral)
|
|
746
|
+
):
|
|
747
|
+
raise TypeError(
|
|
748
|
+
f"'names' must be a list of class names or a number of classes, got {type(names).__name__}"
|
|
749
|
+
)
|
|
750
|
+
if isinstance(names, numbers.Integral):
|
|
751
|
+
if names < 1:
|
|
752
|
+
raise ValueError(
|
|
753
|
+
f"'names' as a number of classes must be at least 1, got {names}"
|
|
754
|
+
)
|
|
755
|
+
names = [str(label) for label in range(names)]
|
|
756
|
+
if not names:
|
|
757
|
+
raise ValueError("'names' is empty, so there are no classes to name")
|
|
758
|
+
for label, name in enumerate(names):
|
|
759
|
+
if not isinstance(name, str):
|
|
760
|
+
raise TypeError(
|
|
761
|
+
f"'names[{label}]' must be a string, got {type(name).__name__}"
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# C L A S S E S: one color and one name per label, in label order
|
|
765
|
+
return {
|
|
766
|
+
label: (_class_color(label, len(names)), name)
|
|
767
|
+
for label, name in enumerate(names)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# endregion
|
|
772
|
+
# endregion
|
|
773
|
+
# endregion
|