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