netloader 3.11.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.
@@ -0,0 +1,677 @@
1
+ """
2
+ Misc functions used elsewhere
3
+ """
4
+ from __future__ import annotations
5
+ import os
6
+ import sys
7
+ import inspect
8
+ import logging as log
9
+ from copy import deepcopy
10
+ from types import ModuleType
11
+ from contextlib import contextmanager
12
+ from typing import Any, Literal, Generator, cast, overload
13
+
14
+ import torch
15
+ import numpy as np
16
+ from torch import Tensor
17
+ from numpy import ndarray
18
+ from packaging.version import Version
19
+
20
+ from netloader.utils.types import ArrayT
21
+
22
+
23
+ class Shapes(list[list[int] | list[list[int]]]):
24
+ """
25
+ Shapes list that allows for indexing of multiple shapes.
26
+ """
27
+ @overload # type: ignore[override]
28
+ def __getitem__(self, idx: int | tuple[int, int]) -> list[int]: ...
29
+
30
+ @overload
31
+ def __getitem__(self, idx: slice) -> Shapes: ...
32
+
33
+ def __getitem__(self, idx: int | tuple[int, int] | slice) -> list[int] | Shapes:
34
+ """
35
+ Get shape from the shapes list.
36
+
37
+ Parameters
38
+ ----------
39
+ idx : int | tuple[int, int] | slice
40
+ Index of the shape to get, if tuple then first index is the layer index and second is
41
+ the shape index if the layer has multiple shapes, if slice, returns a Shapes object with
42
+ the sliced shapes
43
+
44
+ Returns
45
+ -------
46
+ list[int] | Shapes
47
+ Shape at the provided index
48
+ """
49
+ return self.get(idx, list_=False)
50
+
51
+ @overload # type: ignore[override]
52
+ def __setitem__(self, idx: slice, value: list[list[int] | list[list[int]]]) -> None: ...
53
+
54
+ @overload
55
+ def __setitem__(self, idx: tuple[int, int], value: list[int]) -> None: ...
56
+
57
+ @overload
58
+ def __setitem__(self, idx: int, value: list[int] | list[list[int]]) -> None: ...
59
+
60
+ def __setitem__(
61
+ self,
62
+ idx: int | tuple[int, int] | slice,
63
+ value: list[int] | list[list[int]] | list[list[int] | list[list[int]]]) -> None:
64
+ """
65
+ Sets shape in the shapes list.
66
+
67
+ Parameters
68
+ ----------
69
+ idx : int | tuple[int, int] | slice
70
+ Index of the shape to set, if tuple then first index is the layer index and second is
71
+ the shape index if the layer has multiple shapes, if slice, sets multiple shapes
72
+ value : list[int] | list[list[int]]
73
+ Shape to set at the provided index
74
+ """
75
+ if isinstance(idx, tuple):
76
+ if not isinstance(self[idx[0]][0], list):
77
+ raise ValueError(f'Index {idx} does not contain multiple shapes, shape at index '
78
+ f'is {self[idx[0]]}')
79
+ if not isinstance(value[0], int):
80
+ raise ValueError(f'Index {idx} requires a single shape, got {value}')
81
+
82
+ cast(list[list[int]], self[idx[0]])[idx[1]] = cast(list[int], value)
83
+ elif isinstance(idx, int):
84
+ if isinstance(value[0], list) and isinstance(value[0][0], list):
85
+ raise ValueError(f'If index is an int, value must be a single shape or a list of '
86
+ f'shapes, got {value}')
87
+ super().__setitem__(idx, cast(list[int] | list[list[int]], value))
88
+ else:
89
+ if isinstance(value[0], int):
90
+ raise ValueError(f'If index is a slice, value must be a list of shapes, got '
91
+ f'{value}')
92
+ super().__setitem__(idx, cast(list[list[int] | list[list[int]]], value))
93
+
94
+ def __repr__(self) -> str:
95
+ return f'Shapes({super().__repr__()})'
96
+
97
+ def check(self, idx: int) -> bool:
98
+ """
99
+ Checks if the shape at the provided index is a single shape.
100
+
101
+ Parameters
102
+ ----------
103
+ idx : int
104
+ Index of the shape to check
105
+
106
+ Returns
107
+ -------
108
+ bool
109
+ True if the shape at the provided index is a single shape
110
+ """
111
+ if isinstance(self.get(idx, list_=True)[0], int):
112
+ return True
113
+ return False
114
+
115
+ @overload
116
+ def get(self, idx: slice, list_: bool) -> Shapes: ...
117
+
118
+ @overload
119
+ def get(self, idx: tuple[int, int], list_: bool) -> list[int]: ...
120
+
121
+ @overload
122
+ def get(self, idx: int, list_: Literal[False]) -> list[int]: ...
123
+
124
+ @overload
125
+ def get(self, idx: int, list_: Literal[True]) -> list[int] | list[list[int]]: ...
126
+
127
+ @overload
128
+ def get(self, idx: int, list_: bool) -> list[int] | list[list[int]]: ...
129
+
130
+ def get(
131
+ self,
132
+ idx: int | tuple[int, int] | slice,
133
+ list_: bool = True) -> list[int] | list[list[int]] | Shapes:
134
+ """
135
+ Get shape from the shapes list.
136
+
137
+ Parameters
138
+ ----------
139
+ idx : int | tuple[int, int] | slice
140
+ Index of the shape to get, if tuple then first index is the layer index and second is
141
+ the shape index if the layer has multiple shapes, if slice, returns a Shapes object with
142
+ the sliced shapes
143
+ list_ : bool, Optional
144
+ If true, returns a list of shapes if the index contains multiple shapes, else returns a
145
+ single shape, default = True
146
+
147
+ Returns
148
+ -------
149
+ list[int] | list[list[int]] | Shapes
150
+ Shape at the provided index
151
+ """
152
+ item: list[int] | list[list[int]] | list[list[int] | list[list[int]]] = super().__getitem__(
153
+ idx if isinstance(idx, (int, slice)) else idx[0],
154
+ )
155
+
156
+ if isinstance(idx, slice):
157
+ return Shapes(cast(list[list[int] | list[list[int]]], item))
158
+ if isinstance(idx, tuple) and isinstance(item[0], list):
159
+ return cast(list[int], item[idx[1]])
160
+ if isinstance(idx, tuple):
161
+ raise ValueError(f'Index {idx} does not contain multiple shapes, shape at index is '
162
+ f'{item}')
163
+ if isinstance(item[0], list) and not list_:
164
+ raise ValueError(f'Index {idx} does not contain a single shape, shape at index is '
165
+ f'{item}, if you want to return all shapes at that index, use '
166
+ f'Shapes.get(idx, list_=True) instead')
167
+ return cast(list[int], item)
168
+
169
+
170
+ def ascii_plot(
171
+ data: list[float],
172
+ *,
173
+ clear: bool = False,
174
+ x_num: int = 20,
175
+ height: int = 6,
176
+ text: str = '',
177
+ label: str = '',
178
+ data2: list[float] | None = None) -> None:
179
+ """
180
+ Prints a simple ASCII graph of data per index.
181
+
182
+ Parameters
183
+ ----------
184
+ data : list[float]
185
+ Data to plot
186
+ clear : bool, Optional
187
+ If true, clears the previous graph, WARNING: this is very buggy and may not work properly,
188
+ default = False
189
+ x_num : int, Optional
190
+ Maximum number of epochs to display in the graph, default = 20
191
+ height : int, Optional
192
+ Height of the graph in lines, default = 6
193
+ text : str, Optional
194
+ Optional text to display at the end of the graph
195
+ label : str, Optional
196
+ Label for the x-axis
197
+ data2 : list[float] | None, Optional
198
+ Secondary data to plot
199
+ """
200
+ if len(data) < 2:
201
+ return
202
+
203
+ i: int
204
+ row: int
205
+ row2: int
206
+ spacing: int = int(np.ceil(np.log10(len(data)))) + 1
207
+ datum: float
208
+ datum2: float | None
209
+ line: str
210
+ symbol: str
211
+ symbol2: str
212
+ x_label: str = f'{label}:' if label else ''
213
+ grid: list[str]
214
+ symbols: list[str] = ['_', '-', '‾'] # low, mid, high
215
+ symbols2: list[str] = ['.', '·', '˙']
216
+ rows: ndarray
217
+ red_data2: ndarray
218
+ red_data: ndarray = np.array(data)[-x_num:]
219
+
220
+ if data2:
221
+ red_data2 = np.array(data2)[-x_num:]
222
+ range_ = (min(red_data.min(), red_data2.min()), max(red_data.max(), red_data2.max()))
223
+ red_data2 = (red_data2 - range_[0]) / (range_[1] - range_[0])
224
+ else:
225
+ red_data2 = np.array([None] * len(red_data))
226
+ range_ = (red_data.min(), red_data.max())
227
+
228
+ rows = np.linspace(0, 1, height + 1)
229
+ red_data = (red_data - range_[0]) / (range_[1] - range_[0])
230
+ grid = [''] * height
231
+ rows[-1] += 1
232
+
233
+ if clear:
234
+ sys.stdout.write(f'\033[{height + 2}F')
235
+
236
+ for i, (datum, datum2) in enumerate(zip(red_data, red_data2)):
237
+ row = int(np.digitize(datum, rows)) - 1
238
+ symbol = symbols[min(
239
+ int((datum - rows[row]) // (1 / len(symbols) / height)),
240
+ len(symbols) - 1,
241
+ )]
242
+
243
+ if datum2 is not None:
244
+ row2 = int(np.digitize(datum2, rows)) - 1
245
+ symbol2 = symbols2[min(
246
+ int((datum2 - rows[row2]) // (1 / len(symbols2) / height)),
247
+ len(symbols2) - 1,
248
+ )]
249
+
250
+ if row == row2:
251
+ symbol = '*'
252
+ else:
253
+ grid[row2] = f'{grid[row2]:<{i * spacing + len(x_label) + 1}}{symbol2 * spacing}'
254
+
255
+ grid[row] = f'{grid[row]:<{i * spacing + len(x_label) + 1}}{symbol * spacing}'
256
+
257
+ for line in grid[::-1]:
258
+ print(line)
259
+
260
+ print(
261
+ x_label,
262
+ ''.join(f'{i + 1:^{spacing}}' for i in range(max(0, len(data) - x_num), len(data))),
263
+ f'\n{text}',
264
+ )
265
+
266
+
267
+ def check_params(name: str, supported_params: list[str] | ndarray, in_params: ndarray) -> None:
268
+ """
269
+ Checks if provided parameters are supported by the function.
270
+
271
+ Parameters
272
+ ----------
273
+ name : str
274
+ Name of the function
275
+ supported_params : list[str] | ndarray
276
+ Parameters supported by the function
277
+ in_params : ndarray
278
+ Input parameters of shape (N), where N is the number of parameters
279
+ """
280
+ bad_params: ndarray = in_params[~np.isin(in_params, supported_params)]
281
+
282
+ if len(bad_params):
283
+ log.getLogger(__name__).warning(f'Unknown parameters for {name}: {bad_params}')
284
+
285
+
286
+ def compare_versions(cur_ver: str, min_ver: str) -> bool:
287
+ """
288
+ Compares two version strings.
289
+
290
+ Parameters
291
+ ----------
292
+ cur_ver : str
293
+ Current version
294
+ min_ver : str
295
+ Minimum version
296
+
297
+ Returns
298
+ -------
299
+ bool
300
+ True if current version is greater than or equal to minimum version
301
+ """
302
+ if not bool(cur_ver):
303
+ return True
304
+ return not '<' in cur_ver and Version(cur_ver) >= Version(min_ver)
305
+
306
+
307
+ def deep_merge(base: dict, new: dict) -> dict:
308
+ """
309
+ Performs a deep merge of two dictionaries, equivalent to recursive base | new
310
+
311
+ Parameters
312
+ ----------
313
+ base : dict
314
+ Base dictionary
315
+ new : dict
316
+ Dictionary to deep merge into base
317
+
318
+ Returns
319
+ -------
320
+ dict
321
+ Deep merged dictionary
322
+ """
323
+ merged: dict = base.copy()
324
+ key: Any
325
+ value: Any
326
+
327
+ for key, value in new.items():
328
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
329
+ merged[key] = deep_merge(merged[key], value)
330
+ else:
331
+ merged[key] = value
332
+ return merged
333
+
334
+
335
+ def dict_list_append(
336
+ dict1: dict[str, Any],
337
+ dict2: dict[str, Any],
338
+ concat: bool = False,
339
+ balance: bool = False) -> dict[str, Any]:
340
+ """
341
+ Merges two dictionaries.
342
+ If a key exists in both dictionaries and not concat, the data type must match.
343
+ If data type is list or ndarray, the values are extended.
344
+ If data type is any other type and not concat, the value from the second dictionary overrides
345
+ the first, else if concat, the values are converted to a list and appended.
346
+ If balance is True, all lists and ndarrays in each dictionary must have the same length, if a
347
+ key is missing from one dictionary, it is added with None values to match the length of the
348
+ other lists/ndarrays in that dictionary.
349
+
350
+ Parameters
351
+ ----------
352
+ dict1 : dict[str, Any]
353
+ Primary dict to merge secondary dict into
354
+ dict2 : dict[str, Any]
355
+ Secondary dict to merge into primary dict
356
+ concat : bool, Optional
357
+ If true, concatenates non list/ndarray types instead of overriding them, default = False
358
+ balance : bool, Optional
359
+ If true, balances the lengths of lists/ndarrays in each dictionary so that each list/ndarray
360
+ in the returning dictionary has the same length, default = False
361
+
362
+ Returns
363
+ -------
364
+ dict[str, Any]
365
+ First dict with second dict merged into it
366
+ """
367
+ dict1_len: int = 0
368
+ dict2_len: int = 0
369
+ key: str
370
+ dict2 = deepcopy(dict2)
371
+
372
+ if not concat:
373
+ for key in np.intersect1d(list(dict1.keys()), list(dict2.keys())).tolist():
374
+ if not isinstance(dict1[key], type(dict2[key])):
375
+ raise TypeError(f'Type mismatch for key "{key}": {type(dict1[key])} != '
376
+ f'{type(dict2[key])}')
377
+
378
+ if balance:
379
+ # If primary dict is not empty, find the length of a list/ndarray in the dictionary
380
+ if len(dict1) > 0:
381
+ for value in dict1.values():
382
+ if isinstance(value, (list, ndarray)):
383
+ dict1_len = len(value)
384
+ break
385
+
386
+ if any(len(value) != dict1_len for value in dict1.values()
387
+ if isinstance(value, (list, ndarray))):
388
+ raise ValueError('All lists/ndarrays in dict1 must have the same length')
389
+
390
+ # If secondary dict is not empty, find the length of a list/ndarray in the dictionary
391
+ if len(dict2) > 0:
392
+ for value in dict2.values():
393
+ if isinstance(value, (list, ndarray)):
394
+ dict2_len = len(value)
395
+ break
396
+
397
+ if any(len(value) != dict2_len for value in dict2.values()
398
+ if isinstance(value, (list, ndarray))):
399
+ raise ValueError('All lists/ndarrays in dict2 must have the same length')
400
+
401
+ # Merge two dictionaries
402
+ for key in np.unique(list(dict1.keys()) + list(dict2.keys())).tolist():
403
+ # If the secondary dict has a key not in the primary, pad with Nones
404
+ if key in dict1 or not balance:
405
+ pass
406
+ elif isinstance(dict2[key], ndarray):
407
+ dict1[key] = np.full(
408
+ [dict1_len] + (list(dict2[key][0].shape) if dict2[key].ndim > 1 else []),
409
+ None,
410
+ )
411
+ elif isinstance(dict2[key], list) or concat:
412
+ dict1[key] = [None] * dict1_len
413
+
414
+ if concat and key in dict1 and not isinstance(dict1[key], (list, ndarray)):
415
+ dict1[key] = [dict1[key]]
416
+
417
+ if concat and key in dict2 and not isinstance(dict2[key], (list, ndarray)):
418
+ dict2[key] = [dict2[key]]
419
+
420
+ # If the primary dict has a key not in the secondary dict, pad with Nones, else merge dicts
421
+ if key not in dict2 and not balance:
422
+ continue
423
+ if key not in dict1:
424
+ dict1[key] = dict2[key]
425
+ elif balance and key not in dict2 and isinstance(dict1[key], ndarray):
426
+ dict1[key] = np.concat(
427
+ (dict1[key], [np.full( # type: ignore[arg-type]
428
+ dict1[key][0].shape,
429
+ None,
430
+ ) if dict1[key].ndim > 1 else None] * dict2_len),
431
+ axis=0,
432
+ )
433
+ elif balance and key not in dict2 and isinstance(dict1[key], list):
434
+ dict1[key].extend([None] * dict2_len)
435
+ elif isinstance(dict1[key], ndarray):
436
+ dict1[key] = np.concatenate((dict1[key], dict2[key]), axis=0)
437
+ elif isinstance(dict1[key], list):
438
+ dict1[key].extend(dict2[key])
439
+ elif key in dict2:
440
+ dict1[key] = dict2[key]
441
+ return dict1
442
+
443
+
444
+ def dict_list_convert(data: dict[str, list[Any] | ndarray]) -> list[dict[str, Any]]:
445
+ """
446
+ Converts a dictionary of lists to a list of dictionaries.
447
+
448
+ Parameters
449
+ ----------
450
+ data : dict[str, list[Any] | ndarray]
451
+ Dictionary of lists to convert
452
+
453
+ Returns
454
+ -------
455
+ list[dict[str, Any]]
456
+ List of dictionaries
457
+ """
458
+ return [{key: data[key][i:i + 1] for key in data} for i in range(len(list(data.values())[0]))]
459
+
460
+
461
+ def get_device() -> tuple[dict[str, Any], torch.device]:
462
+ """
463
+ Gets the device for PyTorch to use.
464
+
465
+ Returns
466
+ -------
467
+ tuple[dict[str, Any], torch.device]
468
+ Arguments for the PyTorch DataLoader to use when loading data into memory and PyTorch device
469
+ """
470
+ device: torch.device = torch.device(
471
+ 'cuda' if torch.cuda.is_available() else
472
+ 'mps' if torch.backends.mps.is_available() else
473
+ 'cpu',
474
+ )
475
+ kwargs: dict[str, Any] = {
476
+ 'num_workers': 4,
477
+ 'pin_memory': True,
478
+ 'persistent_workers': True,
479
+ } if device == torch.device('cuda') else {}
480
+ return kwargs, device
481
+
482
+
483
+ def label_change(
484
+ data: ArrayT,
485
+ in_label: ArrayT,
486
+ *,
487
+ one_hot: bool = False,
488
+ out_label: ArrayT | None = None) -> ArrayT:
489
+ """
490
+ Converts an array or tensor of class values to an array or tensor of class indices.
491
+
492
+ Parameters
493
+ ----------
494
+ data : ArrayT
495
+ Classes of shape (N) where N is the number of samples
496
+ in_label : ArrayT
497
+ Unique class values of size (C) where C is the number of classes
498
+ one_hot : bool, Optional
499
+ If the returned tensor should be 1D array of class indices or 2D one hot tensor if out_label
500
+ is None or is an int, default = False
501
+ out_label : ArrayT | None, Optional
502
+ Unique class values of size (C) to transform data into, if None, then values will be indexes
503
+
504
+ Returns
505
+ -------
506
+ ArrayT
507
+ ndarray or Tensor of class indices of shape (N) and type float, or if one_hot is True, one
508
+ hot tensor of shape (N,C) and type float
509
+ """
510
+ data_one_hot: ArrayT
511
+ out_data: ArrayT
512
+ module: ModuleType
513
+
514
+ if isinstance(data, Tensor):
515
+ module = torch
516
+ elif isinstance(data, ndarray):
517
+ module = np
518
+ else:
519
+ raise TypeError(f'Data type {type(data)} not supported')
520
+
521
+ if out_label is None:
522
+ out_label = module.arange(len(in_label))
523
+
524
+ if isinstance(out_label, Tensor):
525
+ out_label = cast(ArrayT, out_label.to(data.device))
526
+
527
+ out_data = cast(ArrayT, out_label[module.searchsorted(in_label, data)])
528
+
529
+ if one_hot:
530
+ data_one_hot = module.zeros((len(data), len(in_label)))
531
+ data_one_hot[module.arange(len(data)), out_data] = 1
532
+ out_data = data_one_hot
533
+
534
+ return cast(ArrayT, out_data.to(data.device) if isinstance(out_data, Tensor) else out_data)
535
+
536
+
537
+ def list_dict_convert(
538
+ data: list[dict[str, Any]],
539
+ balance: bool = False,
540
+ concat: bool = False) -> dict[str, list[Any]]:
541
+ """
542
+ Converts a list of dictionaries to a dictionary of lists
543
+
544
+ Parameters
545
+ ----------
546
+ data : list[dict[str, float | ndarray]]
547
+ List of dictionaries to convert
548
+ balance : bool, Optional
549
+ If true, balances the lengths of lists/ndarrays in each dictionary so that each list/ndarray
550
+ in the returning dictionary has the same length, default = False
551
+ concat : bool, Optional
552
+ If true, concatenates non list/ndarray types instead of overriding them, default = False
553
+
554
+ Returns
555
+ -------
556
+ dict[str, list[float | None] | list[ndarray]]
557
+ Dictionary of lists
558
+ """
559
+ value: dict[str, float | ndarray]
560
+ new_data: dict[str, list[float] | list[ndarray]] = {}
561
+
562
+ for value in data:
563
+ dict_list_append(new_data, value, concat=concat, balance=balance)
564
+ return new_data
565
+
566
+
567
+ def progress_bar(i: int, total: int, *, text: str = '', **kwargs: Any) -> None:
568
+ """
569
+ Terminal progress bar.
570
+
571
+ Parameters
572
+ ----------
573
+ i : int
574
+ Current progress
575
+ total : int
576
+ Completion number
577
+ text : str, Optional
578
+ Optional text to place at the end of the progress bar
579
+ **kwargs
580
+ Optional keyword arguments to pass to print
581
+ """
582
+ filled: int
583
+ length: int = 50
584
+ percent: float
585
+ bar_fill: str
586
+
587
+ i += 1
588
+ filled = int(i * length / total)
589
+ percent = i * 100 / total
590
+ bar_fill = '█' * filled + '-' * (length - filled)
591
+ print(f'\rProgress: |{bar_fill}| {int(percent)}%\t{text}\t', end='', **kwargs)
592
+
593
+ if i == total:
594
+ print()
595
+
596
+
597
+ def safe_globals(safe_package: str, modules: ModuleType | list[ModuleType]) -> None:
598
+ """
599
+ Adds all classes in the module from the specified package to the list of safe PyTorch classes
600
+ when loading saved architectures.
601
+
602
+ Parameters
603
+ ----------
604
+ safe_package : str
605
+ Package that classes must belong to to be added to the safe globals
606
+ modules : ModuleType | list[ModuleType]
607
+ Module(s) to add all classes from to the list of safe PyTorch classes when loading saved
608
+ architectures
609
+ """
610
+ if isinstance(modules, ModuleType):
611
+ modules = [modules]
612
+
613
+ for module in modules:
614
+ torch.serialization.add_safe_globals([
615
+ member[1] for member in inspect.getmembers(sys.modules[module.__name__])
616
+ if isinstance(member[1], type) and
617
+ member[1].__module__.split('.', maxsplit=1)[0] == safe_package
618
+ ])
619
+
620
+
621
+ def save_name(num: int | str, states_dir: str, name: str) -> str:
622
+ """
623
+ Standardises the architecture save file naming.
624
+
625
+ Parameters
626
+ ----------
627
+ num : int | str
628
+ File number or name
629
+ states_dir : str
630
+ Directory of network saves
631
+ name : str
632
+ Name of the network
633
+
634
+ Returns
635
+ -------
636
+ str
637
+ Path to the network save file
638
+ """
639
+ return os.path.join(states_dir, f'{name}_{num}.pth')
640
+
641
+ @contextmanager
642
+ def suppress_logger_warnings(logger_name: str) -> Generator[None, None, None]:
643
+ """
644
+ Suppresses warnings from a specified logger.
645
+
646
+ Parameters
647
+ ----------
648
+ logger_name : str
649
+ Name of the logger to suppress warnings from
650
+ """
651
+ level: int
652
+ logger: log.Logger = log.getLogger(logger_name)
653
+ level = logger.level
654
+ logger.setLevel(log.ERROR)
655
+
656
+ try:
657
+ yield
658
+ finally:
659
+ logger.setLevel(level)
660
+
661
+
662
+ __all__ = [
663
+ 'Shapes',
664
+ 'ascii_plot',
665
+ 'check_params',
666
+ 'compare_versions',
667
+ 'deep_merge',
668
+ 'dict_list_append',
669
+ 'dict_list_convert',
670
+ 'get_device',
671
+ 'label_change',
672
+ 'list_dict_convert',
673
+ 'progress_bar',
674
+ 'safe_globals',
675
+ 'save_name',
676
+ 'suppress_logger_warnings',
677
+ ]