learning-loop-node 0.27.1__py3-none-any.whl → 0.28.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.
- learning_loop_node/data_classes/__init__.py +2 -1
- learning_loop_node/data_classes/general.py +7 -2
- learning_loop_node/data_classes/training.py +24 -1
- learning_loop_node/detector/detector_logic.py +1 -0
- learning_loop_node/detector/detector_node.py +8 -2
- learning_loop_node/helpers/entrypoint.py +9 -1
- learning_loop_node/trainer/exceptions.py +7 -0
- learning_loop_node/trainer/hyperparameters.py +180 -0
- learning_loop_node/trainer/trainer_logic_generic.py +50 -9
- learning_loop_node/trainer/trainer_node.py +8 -1
- learning_loop_node/trainer/validation_set.py +7 -0
- {learning_loop_node-0.27.1.dist-info → learning_loop_node-0.28.0.dist-info}/METADATA +3 -2
- {learning_loop_node-0.27.1.dist-info → learning_loop_node-0.28.0.dist-info}/RECORD +15 -14
- {learning_loop_node-0.27.1.dist-info → learning_loop_node-0.28.0.dist-info}/WHEEL +0 -0
- {learning_loop_node-0.27.1.dist-info → learning_loop_node-0.28.0.dist-info}/top_level.txt +0 -0
|
@@ -30,6 +30,7 @@ from .image_metadata import ImageMetadata, ImagesMetadata
|
|
|
30
30
|
from .socket_response import SocketResponse
|
|
31
31
|
from .training import (
|
|
32
32
|
Errors,
|
|
33
|
+
Hyperparameter,
|
|
33
34
|
PretrainedModel,
|
|
34
35
|
Training,
|
|
35
36
|
TrainingError,
|
|
@@ -45,6 +46,6 @@ __all__ = [
|
|
|
45
46
|
'AnnotationNodeStatus', 'Category', 'Context', 'DetectorStatus', 'ErrorConfiguration',
|
|
46
47
|
'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata',
|
|
47
48
|
'SocketResponse',
|
|
48
|
-
'Errors', 'PretrainedModel', 'Training',
|
|
49
|
+
'Errors', 'Hyperparameter', 'PretrainedModel', 'Training',
|
|
49
50
|
'TrainingError', 'TrainingOut', 'TrainingStateData', 'TrainingStatus',
|
|
50
51
|
]
|
|
@@ -12,6 +12,8 @@ from ..enums import CategoryType
|
|
|
12
12
|
|
|
13
13
|
KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
|
|
14
14
|
|
|
15
|
+
DEFAULT_CONF_THRESHOLD = 0.2
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
@dataclass(**KWONLY_SLOTS)
|
|
17
19
|
class Category():
|
|
@@ -59,8 +61,9 @@ class ModelInformation():
|
|
|
59
61
|
default=None, metadata={"description": "The path of the parent directory of the model in the file system."})
|
|
60
62
|
model_size: Optional[str] = field(default=None, metadata={
|
|
61
63
|
"description": "The size of the model (i.e. the specification or variant of the model architecture)."})
|
|
62
|
-
conf_threshold:
|
|
63
|
-
"description": "The minimum confidence
|
|
64
|
+
conf_threshold: float = field(default=DEFAULT_CONF_THRESHOLD, metadata={
|
|
65
|
+
"description": "The minimum confidence for the detections of this model: Loaded from the model if available. "
|
|
66
|
+
"A detector node's own setting overrides it."})
|
|
64
67
|
|
|
65
68
|
@property
|
|
66
69
|
def context(self):
|
|
@@ -80,6 +83,8 @@ class ModelInformation():
|
|
|
80
83
|
except Exception as exc:
|
|
81
84
|
raise Exception(f"could not read model information from file '{model_info_file_path}'") from exc
|
|
82
85
|
try:
|
|
86
|
+
# a null is a value the writer did not determine, so it reads as if the key were absent
|
|
87
|
+
content = {key: value for key, value in content.items() if value is not None}
|
|
83
88
|
model_information = from_dict(data_class=ModelInformation, data=content)
|
|
84
89
|
model_information.model_root_path = model_root_path
|
|
85
90
|
except Exception as exc:
|
|
@@ -3,7 +3,7 @@ import sys
|
|
|
3
3
|
import time
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Dict, List, Optional
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
7
7
|
from uuid import uuid4
|
|
8
8
|
|
|
9
9
|
from ..enums import TrainerState
|
|
@@ -22,6 +22,28 @@ class PretrainedModel():
|
|
|
22
22
|
description: str
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
HyperparameterType = Literal['int', 'float', 'bool', 'str', 'pairs']
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(**KWONLY_SLOTS)
|
|
29
|
+
class Hyperparameter:
|
|
30
|
+
"""One hyperparameter a trainer accepts, reported to the loop so it can offer it in the project settings.
|
|
31
|
+
|
|
32
|
+
Plain data only, so it reaches the loop as it is. ``minimum`` and ``maximum`` apply to ``int`` and ``float``,
|
|
33
|
+
``choices`` to ``str``. A ``pairs`` value is text of the form ``"key:value,..."``, which ``parse_pairs`` reads.
|
|
34
|
+
``notes`` help whoever sets the value. ``expert`` ones are offered behind an expansion.
|
|
35
|
+
Whether a value can be changed when a training is started is the project's choice, not the trainer's.
|
|
36
|
+
"""
|
|
37
|
+
name: str
|
|
38
|
+
type: HyperparameterType
|
|
39
|
+
default: Any
|
|
40
|
+
minimum: float | None = None
|
|
41
|
+
maximum: float | None = None
|
|
42
|
+
choices: list[str] | None = None
|
|
43
|
+
notes: str = ''
|
|
44
|
+
expert: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
25
47
|
@dataclass(**KWONLY_SLOTS)
|
|
26
48
|
class TrainingStatus():
|
|
27
49
|
id: str # NOTE this must not be changed, but tests wont detect a change -> update tests!
|
|
@@ -33,6 +55,7 @@ class TrainingStatus():
|
|
|
33
55
|
progress: Optional[float]
|
|
34
56
|
|
|
35
57
|
pretrained_models: List[PretrainedModel] = field(default_factory=list)
|
|
58
|
+
hyperparameters: list[Hyperparameter] = field(default_factory=list)
|
|
36
59
|
architecture: Optional[str] = None
|
|
37
60
|
context: Optional[Context] = None
|
|
38
61
|
|
|
@@ -10,6 +10,7 @@ class DetectorLogicFactory(Protocol):
|
|
|
10
10
|
|
|
11
11
|
The factory controls how the detector is constructed — implementations
|
|
12
12
|
can build synchronously or offload heavy work to a thread pool.
|
|
13
|
+
`model_info.conf_threshold` is the confidence the detector filters at.
|
|
13
14
|
"""
|
|
14
15
|
@property
|
|
15
16
|
def model_format(self) -> str: ...
|
|
@@ -7,7 +7,7 @@ import shutil
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
9
|
from collections.abc import Sequence
|
|
10
|
-
from dataclasses import asdict, dataclass
|
|
10
|
+
from dataclasses import asdict, dataclass, replace
|
|
11
11
|
from datetime import datetime
|
|
12
12
|
from typing import Dict, List, Literal, Optional, Union
|
|
13
13
|
|
|
@@ -50,9 +50,11 @@ from .rest import upload as rest_upload
|
|
|
50
50
|
class DetectorNode(Node):
|
|
51
51
|
|
|
52
52
|
def __init__(self, name: str, detector_factory: DetectorLogicFactory,
|
|
53
|
-
uuid: Optional[str] = None, use_backdoor_controls: bool = False
|
|
53
|
+
uuid: Optional[str] = None, use_backdoor_controls: bool = False,
|
|
54
|
+
conf_threshold: Optional[float] = None) -> None:
|
|
54
55
|
super().__init__(name, uuid=uuid, node_type='detector', needs_login=False, needs_sio=False)
|
|
55
56
|
self._detector_factory = detector_factory
|
|
57
|
+
self._conf_threshold = conf_threshold
|
|
56
58
|
self.node_version: Optional[str] = os.environ.get('NODE_VERSION')
|
|
57
59
|
self._detector: _DetectorState = _Initializing()
|
|
58
60
|
self._exclusive_model_build: bool = os.environ.get('EXCLUSIVE_MODEL_BUILD', '0').lower() in ('1', 'true')
|
|
@@ -547,6 +549,10 @@ class DetectorNode(Node):
|
|
|
547
549
|
if model_info is None:
|
|
548
550
|
logging.warning('No model.json found in %s', model_dir)
|
|
549
551
|
raise Exception('model.json not found')
|
|
552
|
+
if self._conf_threshold is not None:
|
|
553
|
+
model_info = replace(model_info, conf_threshold=self._conf_threshold)
|
|
554
|
+
self.log.info('Set confidence threshold for model %s to %s%s', model_info.version,
|
|
555
|
+
model_info.conf_threshold, ' (set on the node)' if self._conf_threshold is not None else '')
|
|
550
556
|
|
|
551
557
|
if self._exclusive_model_build and isinstance(self._detector, _ActiveDetector):
|
|
552
558
|
async with self.detection_lock:
|
|
@@ -12,13 +12,14 @@ from argparse import Action, Namespace
|
|
|
12
12
|
import configargparse
|
|
13
13
|
import uvicorn
|
|
14
14
|
|
|
15
|
+
from ..data_classes.general import DEFAULT_CONF_THRESHOLD
|
|
15
16
|
from ..trainer.batch_size import VRAM_LIMIT_GB_FLAG, VRAM_LIMIT_GB_HELP
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def node_parser(*, description: str, legacy_env_prefix: str = '',
|
|
21
|
-
vram_limit: bool = False) -> configargparse.ArgumentParser:
|
|
22
|
+
vram_limit: bool = False, conf_threshold: bool = False) -> configargparse.ArgumentParser:
|
|
22
23
|
"""Build the parser for a node, pre-loaded with the settings every node has.
|
|
23
24
|
|
|
24
25
|
:param legacy_env_prefix: A prefix an earlier version of this node required, e.g.
|
|
@@ -26,6 +27,9 @@ def node_parser(*, description: str, legacy_env_prefix: str = '',
|
|
|
26
27
|
current name (``MY_DETECTOR_NODE_HOST``) and the prefix on the flag it was originally
|
|
27
28
|
applied to (``MY_DETECTOR_HOST``). Leave empty for a node that never used one.
|
|
28
29
|
:param vram_limit: Add :data:`~learning_loop_node.trainer.batch_size.VRAM_LIMIT_GB_FLAG`.
|
|
30
|
+
:param conf_threshold: Add ``--conf-threshold`` for the ``conf_threshold`` of a
|
|
31
|
+
:class:`~learning_loop_node.detector.detector_node.DetectorNode`. It has no default:
|
|
32
|
+
one would override the threshold the trainer chose for the model.
|
|
29
33
|
"""
|
|
30
34
|
parser = _NodeArgumentParser(description=description, legacy_env_prefix=legacy_env_prefix)
|
|
31
35
|
parser.add_argument('--host', default='0.0.0.0', env_var='NODE_HOST',
|
|
@@ -33,6 +37,10 @@ def node_parser(*, description: str, legacy_env_prefix: str = '',
|
|
|
33
37
|
parser.add_argument('--port', type=int, default=80, env_var='NODE_PORT', help='Port to bind to')
|
|
34
38
|
if vram_limit:
|
|
35
39
|
parser.add_argument(VRAM_LIMIT_GB_FLAG, type=float, default=0, help=VRAM_LIMIT_GB_HELP)
|
|
40
|
+
if conf_threshold:
|
|
41
|
+
parser.add_argument('--conf-threshold', type=float, default=None,
|
|
42
|
+
help='Minimum confidence for detections (default: the one the trainer chose for the '
|
|
43
|
+
f'model, else {DEFAULT_CONF_THRESHOLD})')
|
|
36
44
|
return parser
|
|
37
45
|
|
|
38
46
|
|
|
@@ -15,6 +15,13 @@ class TrainingRefusedError(Exception):
|
|
|
15
15
|
"""Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class HyperparameterMismatchError(Exception):
|
|
19
|
+
"""Raised when a training's hyperparameters do not fit the trainer's declaration; nothing was set up.
|
|
20
|
+
|
|
21
|
+
Unlike a refusal, the training would fail on any trainer of the same declaration.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
class TrainingSlotTakenError(TrainingRefusedError):
|
|
19
26
|
"""Raised when a sibling node holds the training slot."""
|
|
20
27
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Resolving the hyperparameters a training was sent against the ones its trainer declares.
|
|
2
|
+
|
|
3
|
+
The loop sends only what the project sets, plus what the job overrides. Everything else comes from
|
|
4
|
+
the trainer's declared defaults, so after :func:`resolve` a training holds the full set it runs
|
|
5
|
+
with, and that is what gets reported back to the loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import Any, get_args
|
|
13
|
+
|
|
14
|
+
from ..data_classes import Hyperparameter
|
|
15
|
+
from ..data_classes.training import HyperparameterType
|
|
16
|
+
from .batch_size import REQUESTED_BATCH_SIZE
|
|
17
|
+
from .exceptions import CriticalError
|
|
18
|
+
from .validation_set import COMPUTE_VALIDATION_SET
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
LIBRARY_NAMES = (REQUESTED_BATCH_SIZE, COMPUTE_VALIDATION_SET)
|
|
23
|
+
"""Hyperparameters the library reads itself, so a trainer that does not declare them still uses them."""
|
|
24
|
+
|
|
25
|
+
BOOL_STRINGS = {'true': True, '1': True, 'yes': True, 'on': True,
|
|
26
|
+
'false': False, '0': False, 'no': False, 'off': False}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(declared: Sequence[Hyperparameter], received: Mapping[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""Coerce and check ``received`` against ``declared`` and fill in the declared defaults.
|
|
31
|
+
|
|
32
|
+
``declared`` is trusted to have passed :func:`check_declaration`. Names the trainer does not declare are kept
|
|
33
|
+
unchanged: old project configurations still carry them, and a resolved training carries the values its trainer
|
|
34
|
+
reported. An unset hyperparameter with a default of ``None`` is left out, so the trainer's own fallback applies.
|
|
35
|
+
Resolving twice gives the same result.
|
|
36
|
+
|
|
37
|
+
:raises CriticalError: If a value cannot be coerced to its type, is out of range or not one of its choices.
|
|
38
|
+
"""
|
|
39
|
+
resolved = dict(received)
|
|
40
|
+
for hyperparameter in declared:
|
|
41
|
+
value = received.get(hyperparameter.name)
|
|
42
|
+
if _is_unset(hyperparameter, value):
|
|
43
|
+
if hyperparameter.default is None:
|
|
44
|
+
resolved.pop(hyperparameter.name, None)
|
|
45
|
+
else:
|
|
46
|
+
resolved[hyperparameter.name] = _coerce(hyperparameter, hyperparameter.default)
|
|
47
|
+
else:
|
|
48
|
+
resolved[hyperparameter.name] = _check(hyperparameter, _coerce(hyperparameter, value))
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_declaration(declared: Sequence[Hyperparameter]) -> None:
|
|
53
|
+
"""Check that ``declared`` is consistent, so that :func:`resolve` can rely on it.
|
|
54
|
+
|
|
55
|
+
A trainer's declaration does not change while it runs, so the node checks it once, when it is built. A default of
|
|
56
|
+
``None`` stands for "the trainer decides" and is not checked.
|
|
57
|
+
|
|
58
|
+
:raises ValueError: If a name is declared twice, a hyperparameter contradicts itself or its default does not fit.
|
|
59
|
+
"""
|
|
60
|
+
names = [h.name for h in declared]
|
|
61
|
+
if duplicates := sorted({name for name in names if names.count(name) > 1}):
|
|
62
|
+
raise ValueError(f'the trainer declares these hyperparameters twice: {", ".join(duplicates)}')
|
|
63
|
+
for hyperparameter in declared:
|
|
64
|
+
_check_one_declaration(hyperparameter)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_pairs(text: str) -> list[tuple[str, str]]:
|
|
68
|
+
"""The ``key:value`` items of a ``pairs`` hyperparameter such as ``"uuid:uuid,uuid:uuid"``.
|
|
69
|
+
|
|
70
|
+
Empty items and the spaces around keys and values are skipped, so ``''`` holds no pairs.
|
|
71
|
+
|
|
72
|
+
:raises ValueError: If an item is not a key and a value separated by a single colon.
|
|
73
|
+
"""
|
|
74
|
+
pairs = []
|
|
75
|
+
for item in (i.strip() for i in text.split(',')):
|
|
76
|
+
if not item:
|
|
77
|
+
continue
|
|
78
|
+
key, _, value = (part.strip() for part in item.partition(':'))
|
|
79
|
+
if not key or not value or ':' in value:
|
|
80
|
+
raise ValueError(f'"{item}" is not of the form "key:value"')
|
|
81
|
+
pairs.append((key, value))
|
|
82
|
+
return pairs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def warn_about_undeclared(declared: Sequence[Hyperparameter], received: Mapping[str, Any]) -> None:
|
|
86
|
+
"""Log the names in ``received`` that neither the trainer nor the library knows; they are not rejected."""
|
|
87
|
+
unknown = sorted(set(received) - {h.name for h in declared} - set(LIBRARY_NAMES))
|
|
88
|
+
if unknown:
|
|
89
|
+
logger.warning('these hyperparameters are not declared by this trainer: %s', ', '.join(unknown))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_one_declaration(hyperparameter: Hyperparameter) -> None:
|
|
93
|
+
def contradiction(reason: str) -> ValueError:
|
|
94
|
+
return ValueError(f"the trainer declares hyperparameter '{hyperparameter.name}' {reason}")
|
|
95
|
+
|
|
96
|
+
if hyperparameter.type not in get_args(HyperparameterType):
|
|
97
|
+
raise contradiction(f'with the unknown type {hyperparameter.type!r}')
|
|
98
|
+
if hyperparameter.type not in ('int', 'float') and (hyperparameter.minimum is not None or
|
|
99
|
+
hyperparameter.maximum is not None):
|
|
100
|
+
raise contradiction(f'as {hyperparameter.type} with a minimum or maximum')
|
|
101
|
+
if hyperparameter.type != 'str' and hyperparameter.choices:
|
|
102
|
+
raise contradiction(f'as {hyperparameter.type} with choices')
|
|
103
|
+
for bound in (hyperparameter.minimum, hyperparameter.maximum):
|
|
104
|
+
if bound is not None and (isinstance(bound, bool) or not isinstance(bound, (int, float))):
|
|
105
|
+
raise contradiction(f'with the bound {bound!r}, which is not a number')
|
|
106
|
+
if (hyperparameter.minimum is not None and hyperparameter.maximum is not None and
|
|
107
|
+
hyperparameter.minimum > hyperparameter.maximum):
|
|
108
|
+
raise contradiction('with a minimum above its maximum')
|
|
109
|
+
if hyperparameter.default is not None:
|
|
110
|
+
try:
|
|
111
|
+
_check(hyperparameter, _coerce(hyperparameter, hyperparameter.default))
|
|
112
|
+
except CriticalError as e:
|
|
113
|
+
raise contradiction(f'with a default that does not fit: {e}') from None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_unset(hyperparameter: Hyperparameter, value: Any) -> bool:
|
|
117
|
+
"""``None``, or the empty value the loop sends for a field left blank, unless it fits as a value of its own."""
|
|
118
|
+
if value is None:
|
|
119
|
+
return True
|
|
120
|
+
if value != '':
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
_check(hyperparameter, _coerce(hyperparameter, value))
|
|
124
|
+
except CriticalError:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _invalid(hyperparameter: Hyperparameter, requirement: str, value: Any) -> CriticalError:
|
|
130
|
+
return CriticalError(f"hyperparameter '{hyperparameter.name}' {requirement}, got {value!r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _coerce(hyperparameter: Hyperparameter, value: Any) -> Any:
|
|
134
|
+
if hyperparameter.type == 'pairs':
|
|
135
|
+
if not isinstance(value, str):
|
|
136
|
+
raise _invalid(hyperparameter, 'must be text of key:value pairs', value)
|
|
137
|
+
try:
|
|
138
|
+
return ','.join(f'{key}:{item}' for key, item in parse_pairs(value))
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
raise CriticalError(f"hyperparameter '{hyperparameter.name}': {e}") from None
|
|
141
|
+
if hyperparameter.type == 'str':
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
143
|
+
raise _invalid(hyperparameter, 'must be text', value)
|
|
144
|
+
return str(value)
|
|
145
|
+
if hyperparameter.type == 'bool':
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return value
|
|
148
|
+
if isinstance(value, str) and (word := value.strip().lower()) in BOOL_STRINGS:
|
|
149
|
+
return BOOL_STRINGS[word]
|
|
150
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
151
|
+
number = float(value)
|
|
152
|
+
if math.isfinite(number):
|
|
153
|
+
return number != 0
|
|
154
|
+
raise _invalid(hyperparameter, 'must be true or false', value)
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
raise _invalid(hyperparameter, 'must be a number', value)
|
|
157
|
+
if hyperparameter.type == 'int' and isinstance(value, (int, str)):
|
|
158
|
+
with contextlib.suppress(ValueError):
|
|
159
|
+
return int(value)
|
|
160
|
+
try:
|
|
161
|
+
number = float(value)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
raise _invalid(hyperparameter, 'must be a number', value) from None
|
|
164
|
+
if not math.isfinite(number):
|
|
165
|
+
raise _invalid(hyperparameter, 'must be finite', value)
|
|
166
|
+
if hyperparameter.type == 'float':
|
|
167
|
+
return number
|
|
168
|
+
if not number.is_integer():
|
|
169
|
+
raise _invalid(hyperparameter, 'must be a whole number', value)
|
|
170
|
+
return int(number)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check(hyperparameter: Hyperparameter, value: Any) -> Any:
|
|
174
|
+
if hyperparameter.minimum is not None and value < hyperparameter.minimum:
|
|
175
|
+
raise _invalid(hyperparameter, f'must be at least {hyperparameter.minimum}', value)
|
|
176
|
+
if hyperparameter.maximum is not None and value > hyperparameter.maximum:
|
|
177
|
+
raise _invalid(hyperparameter, f'must be at most {hyperparameter.maximum}', value)
|
|
178
|
+
if hyperparameter.choices and value not in hyperparameter.choices:
|
|
179
|
+
raise _invalid(hyperparameter, f"must be one of {', '.join(hyperparameter.choices)}", value)
|
|
180
|
+
return value
|
|
@@ -13,6 +13,7 @@ from fastapi.encoders import jsonable_encoder
|
|
|
13
13
|
from ..data_classes import (
|
|
14
14
|
Context,
|
|
15
15
|
Errors,
|
|
16
|
+
Hyperparameter,
|
|
16
17
|
PretrainedModel,
|
|
17
18
|
Training,
|
|
18
19
|
TrainingOut,
|
|
@@ -26,7 +27,8 @@ from ..helpers.misc import (
|
|
|
26
27
|
is_valid_uuid4,
|
|
27
28
|
)
|
|
28
29
|
from .downloader import TrainingsDownloader
|
|
29
|
-
from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
|
|
30
|
+
from .exceptions import CriticalError, HyperparameterMismatchError, NodeNeedsRestartError, TrainingRefusedError
|
|
31
|
+
from .hyperparameters import resolve, warn_about_undeclared
|
|
30
32
|
from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
|
|
31
33
|
from .training_slot import TrainingSlot, training_slot_from_env
|
|
32
34
|
from .validation_set import assign_validation_set
|
|
@@ -186,6 +188,12 @@ class TrainerLogicGeneric(ABC):
|
|
|
186
188
|
"""
|
|
187
189
|
raise NotImplementedError
|
|
188
190
|
|
|
191
|
+
@property
|
|
192
|
+
@abstractmethod
|
|
193
|
+
def provided_hyperparameters(self) -> list[Hyperparameter]:
|
|
194
|
+
"""Returns the hyperparameters this trainer accepts; an empty list leaves them unchecked."""
|
|
195
|
+
raise NotImplementedError
|
|
196
|
+
|
|
189
197
|
# ---------------------------------------- METHODS ----------------------------------------
|
|
190
198
|
|
|
191
199
|
# NOTE: Trainings are started by the Learning Loop via the begin_training event
|
|
@@ -204,6 +212,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
204
212
|
progress=self.general_progress)
|
|
205
213
|
|
|
206
214
|
status.pretrained_models = self.provided_pretrained_models
|
|
215
|
+
status.hyperparameters = self.provided_hyperparameters
|
|
207
216
|
status.architecture = self.model_architecture
|
|
208
217
|
|
|
209
218
|
if self._training:
|
|
@@ -224,9 +233,18 @@ class TrainerLogicGeneric(ABC):
|
|
|
224
233
|
|
|
225
234
|
def _init_from_last_training(self) -> None:
|
|
226
235
|
"""Initializes a new training object from the last training saved on disc via last_training_io.
|
|
236
|
+
|
|
237
|
+
The hyperparameters are resolved again, since the training may have been saved before this trainer declared
|
|
238
|
+
them. If one no longer fits, the training continues with the values it was saved with, since it already runs
|
|
239
|
+
with them.
|
|
227
240
|
"""
|
|
228
241
|
self._training = self.last_training_io.load()
|
|
229
242
|
assert self._training is not None and self._training.training_folder is not None, 'could not restore training folder'
|
|
243
|
+
try:
|
|
244
|
+
self._training.hyperparameters = resolve(self.provided_hyperparameters, self._training.hyperparameters)
|
|
245
|
+
except CriticalError as e:
|
|
246
|
+
logger.warning('restored training does not fit the declared hyperparameters, continuing with the saved '
|
|
247
|
+
'values: %s', e)
|
|
230
248
|
logger.info('restored training: \n%s', self._training)
|
|
231
249
|
self._active_training_io = ActiveTrainingIO(
|
|
232
250
|
self._training.training_folder, self.node.loop_communicator, self._training.context)
|
|
@@ -234,13 +252,15 @@ class TrainerLogicGeneric(ABC):
|
|
|
234
252
|
async def begin_training(self, organization: str, project: str, training_config: Dict) -> None:
|
|
235
253
|
"""Called on `begin_training` event from the Learning Loop.
|
|
236
254
|
|
|
237
|
-
The training slot is taken before anything is set up, so a refused
|
|
238
|
-
previous training folders in place.
|
|
255
|
+
The hyperparameters are resolved and the training slot is taken before anything is set up, so a refused
|
|
256
|
+
training leaves the previous training folders in place.
|
|
239
257
|
|
|
240
258
|
:raise TrainingRefusedError: if a training is active or a sibling node holds the training slot.
|
|
259
|
+
:raise HyperparameterMismatchError: if a hyperparameter does not fit its declaration.
|
|
241
260
|
"""
|
|
242
261
|
if self.training_active:
|
|
243
262
|
raise TrainingRefusedError(f'training {self.training.id} is still active')
|
|
263
|
+
training_config = self._with_resolved_hyperparameters(training_config)
|
|
244
264
|
context = Context(organization=organization, project=project)
|
|
245
265
|
self.training_slot.claim(self._slot_claim(context, training_config.get('training_number')))
|
|
246
266
|
try:
|
|
@@ -250,6 +270,24 @@ class TrainerLogicGeneric(ABC):
|
|
|
250
270
|
raise
|
|
251
271
|
self._begin_training_task()
|
|
252
272
|
|
|
273
|
+
def _with_resolved_hyperparameters(self, training_config: dict) -> dict:
|
|
274
|
+
"""Returns ``training_config`` with its hyperparameters resolved against the declared ones.
|
|
275
|
+
|
|
276
|
+
A config without a hyperparameters dict is returned as it is, for ``Training.generate_training`` to reject.
|
|
277
|
+
|
|
278
|
+
:raise HyperparameterMismatchError: if a hyperparameter does not fit its declaration.
|
|
279
|
+
"""
|
|
280
|
+
declared = self.provided_hyperparameters
|
|
281
|
+
received = training_config.get('hyperparameters')
|
|
282
|
+
if not declared or not isinstance(received, dict):
|
|
283
|
+
return training_config
|
|
284
|
+
try:
|
|
285
|
+
resolved = resolve(declared, received)
|
|
286
|
+
except CriticalError as e:
|
|
287
|
+
raise HyperparameterMismatchError(str(e)) from e
|
|
288
|
+
warn_about_undeclared(declared, received)
|
|
289
|
+
return {**training_config, 'hyperparameters': resolved}
|
|
290
|
+
|
|
253
291
|
def _begin_training_task(self) -> None:
|
|
254
292
|
# NOTE: Task object is used to potentially cancel the task
|
|
255
293
|
self.training_task = asyncio.get_event_loop().create_task(self._run())
|
|
@@ -498,10 +536,11 @@ class TrainerLogicGeneric(ABC):
|
|
|
498
536
|
raise CriticalError('The model is uploaded, but the loop never answered with its id') from e
|
|
499
537
|
|
|
500
538
|
def _dump_model_json(self) -> str:
|
|
501
|
-
"""
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
539
|
+
"""Dump the categories and the trainer's model metadata to a json file and return its path."""
|
|
540
|
+
content = None
|
|
541
|
+
if self._training:
|
|
542
|
+
content = {'categories': [asdict(c) for c in self._training.categories],
|
|
543
|
+
**{key: value for key, value in self._get_model_metadata().items() if value is not None}}
|
|
505
544
|
json_path = '/tmp/model.json'
|
|
506
545
|
with open(json_path, 'w') as f:
|
|
507
546
|
json.dump(content, f)
|
|
@@ -620,9 +659,11 @@ class TrainerLogicGeneric(ABC):
|
|
|
620
659
|
raise NotImplementedError
|
|
621
660
|
|
|
622
661
|
def _get_model_metadata(self) -> dict[str, Any]:
|
|
623
|
-
"""
|
|
624
|
-
|
|
662
|
+
"""Return the values the trainer determined for this model, to be merged into its model.json.
|
|
663
|
+
|
|
664
|
+
Called when the files of `_get_latest_model_files` are uploaded.
|
|
625
665
|
The keys must be fields of `ModelInformation` (e.g. `conf_threshold`) so detectors can read them.
|
|
666
|
+
A value of `None` is left out of the file.
|
|
626
667
|
"""
|
|
627
668
|
return {}
|
|
628
669
|
|
|
@@ -10,7 +10,8 @@ from socketio import AsyncClient, exceptions
|
|
|
10
10
|
from ..data_classes import SocketResponse
|
|
11
11
|
from ..enums import TrainerState
|
|
12
12
|
from ..node import Node
|
|
13
|
-
from .exceptions import TrainingRefusedError
|
|
13
|
+
from .exceptions import HyperparameterMismatchError, TrainingRefusedError
|
|
14
|
+
from .hyperparameters import check_declaration
|
|
14
15
|
from .io_helpers import LastTrainingIO
|
|
15
16
|
from .rest import backdoor_controls
|
|
16
17
|
from .trainer_logic_generic import TrainerLogicGeneric
|
|
@@ -19,6 +20,8 @@ from .trainer_logic_generic import TrainerLogicGeneric
|
|
|
19
20
|
class TrainerNode(Node):
|
|
20
21
|
|
|
21
22
|
def __init__(self, name: str, trainer_logic: TrainerLogicGeneric, uuid: Optional[str] = None, use_backdoor_controls: bool = False):
|
|
23
|
+
""":raise ValueError: if the trainer declares hyperparameters that contradict themselves."""
|
|
24
|
+
check_declaration(trainer_logic.provided_hyperparameters)
|
|
22
25
|
super().__init__(name, uuid=uuid, node_type='trainer')
|
|
23
26
|
trainer_logic._node = self
|
|
24
27
|
self.trainer_logic = trainer_logic
|
|
@@ -72,6 +75,10 @@ class TrainerNode(Node):
|
|
|
72
75
|
except TrainingRefusedError as e:
|
|
73
76
|
self.log.warning('refused begin_training: %s', e)
|
|
74
77
|
return SocketResponse(success=False, error_msg=str(e), payload='refused')
|
|
78
|
+
except HyperparameterMismatchError as e:
|
|
79
|
+
# NOTE: not a refusal, which the loop answers by offering the same job again
|
|
80
|
+
self.log.warning('rejected begin_training: %s', e)
|
|
81
|
+
return SocketResponse.for_failure(str(e))
|
|
75
82
|
return True
|
|
76
83
|
|
|
77
84
|
@sio_client.event
|
|
@@ -21,6 +21,7 @@ import logging
|
|
|
21
21
|
from collections.abc import Mapping
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from ..data_classes import Hyperparameter
|
|
24
25
|
from .exceptions import CriticalError
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
@@ -28,6 +29,12 @@ logger = logging.getLogger(__name__)
|
|
|
28
29
|
COMPUTE_VALIDATION_SET = 'compute_validation_set'
|
|
29
30
|
"""The hyperparameter that asks to split a validation set off the train images when the loop sent none."""
|
|
30
31
|
|
|
32
|
+
COMPUTE_VALIDATION_SET_HYPERPARAMETER = Hyperparameter(
|
|
33
|
+
name=COMPUTE_VALIDATION_SET, type='bool', default=False,
|
|
34
|
+
notes='Split about a tenth of the train images off to select the models on, so the test set is only reported on; '
|
|
35
|
+
'a validation set sent by the loop is used either way')
|
|
36
|
+
"""The declaration of `compute_validation_set`, for a trainer to list among its `provided_hyperparameters`."""
|
|
37
|
+
|
|
31
38
|
VALIDATION_FRACTION = 0.1
|
|
32
39
|
|
|
33
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
|
|
5
5
|
Author-email: Zauberzeug GmbH <info@zauberzeug.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,6 +61,7 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
61
61
|
| MIN_UNCERTAIN_THRESHOLD | - | smallest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.3 |
|
|
62
62
|
| MAX_UNCERTAIN_THRESHOLD | - | largest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.6 |
|
|
63
63
|
| MAX_OUTBOX_SIZE | - | max. number of non-priority images queued for upload | Detector (opt.) | 1000 |
|
|
64
|
+
| CONF_THRESHOLD | - | minimum confidence of a detection, overriding the model's (`--conf-threshold`, detectors built with `node_parser(conf_threshold=True)`) | Detector (opt.) | model's, 0.2 |
|
|
64
65
|
| EXCLUSIVE_MODEL_BUILD | - | Reject detections during update to save VRAM (set to 1) | Detector (opt.) | 0 |
|
|
65
66
|
| INFERENCE_BATCH_SIZE | - | Batch size of trainer when calculating detections | Trainer (opt.) | 10 |
|
|
66
67
|
| VRAM_LIMIT_GB | - | GPU memory (GB) a training may use; the batch size is probed against it (`--vram-limit-gb`, trainers built with `node_parser(vram_limit=True)`) | Trainer (opt.) | 0 (whole card) |
|
|
@@ -349,4 +350,4 @@ After a short time the converted model should be available as well.
|
|
|
349
350
|
- Nodes add properties to `model.json`, which contains all the information which are needed by subsequent nodes. These are typically the properties:
|
|
350
351
|
- `resolution`: resolution in which the model expects images (as `int`, since the resolution is mostly square - later, `resolution_x`` resolution_y` would also be conceivable or `resolutions` to give a list of possible resolutions)
|
|
351
352
|
- `categories`: list of categories with name, id, (later also type), in the order in which they are used by the model -- this is neccessary to be robust about renamings
|
|
352
|
-
- `conf_threshold` (optional): the minimum confidence the trainer chose for this model's detections
|
|
353
|
+
- `conf_threshold` (optional): the minimum confidence the trainer chose for this model's detections; a detector filters at it unless its `CONF_THRESHOLD` is set, and at 0.2 for a model without one
|
|
@@ -8,18 +8,18 @@ learning_loop_node/rest.py,sha256=5X9IVW9kf1gNf8jifGW9g_gI_-9TEeoMMOW16jvwpRE,15
|
|
|
8
8
|
learning_loop_node/annotation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
learning_loop_node/annotation/annotator_logic.py,sha256=BTaopkJZkIf1CI5lfsVKsxbxoUIbDJrevavuQUT5e_c,1000
|
|
10
10
|
learning_loop_node/annotation/annotator_node.py,sha256=J5xwSnM5rwTWrTe-TI37J0JHKf_4PlDuABaHvgjYr_Q,4443
|
|
11
|
-
learning_loop_node/data_classes/__init__.py,sha256=
|
|
11
|
+
learning_loop_node/data_classes/__init__.py,sha256=f9txmNcmpxCfTmgLIwZS0tr1QcJHI45s8Rxgbxk_U7o,1392
|
|
12
12
|
learning_loop_node/data_classes/annotation_data.py,sha256=NfMlTv2_5AfVY_JDM4tbjETFjSN2S2I2LJJPMMcDT50,966
|
|
13
13
|
learning_loop_node/data_classes/annotations.py,sha256=ha7uuWmZqW-LU2vYrkvfnaVpvf5C2xdm826IVVBu0w0,1848
|
|
14
14
|
learning_loop_node/data_classes/detections.py,sha256=hGSKc1elk4Drp4XUjQ3F9HGeRpyLvA0q064b-S9lIug,5335
|
|
15
|
-
learning_loop_node/data_classes/general.py,sha256=
|
|
15
|
+
learning_loop_node/data_classes/general.py,sha256=0p6RhPOEVr_vBoG-TySTUKhjT2_AS9Jb8jEqSkg5ncc,7829
|
|
16
16
|
learning_loop_node/data_classes/image_metadata.py,sha256=QPLJ4gcVj33xaaCFF1n5e8G6higOJ6iIKy3YhGZMfkw,2459
|
|
17
17
|
learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
|
|
18
|
-
learning_loop_node/data_classes/training.py,sha256=
|
|
18
|
+
learning_loop_node/data_classes/training.py,sha256=My56tpDHAaUlraACTh-ZJ3y2V6v23IkD_bkkvil9_S8,6902
|
|
19
19
|
learning_loop_node/detector/__init__.py,sha256=wroy0W1X5MX16C-peA-UHYrcdXet7GhleIlC_Pqa6hI,128
|
|
20
20
|
learning_loop_node/detector/categories.py,sha256=qgquuop3geKXXsNBurO0eC2b64h_ojL37RBYMv0ZzOg,1147
|
|
21
|
-
learning_loop_node/detector/detector_logic.py,sha256=
|
|
22
|
-
learning_loop_node/detector/detector_node.py,sha256=
|
|
21
|
+
learning_loop_node/detector/detector_logic.py,sha256=agPbkFvFuQhZ04qQleWji6iYrbXsKqVGL0fseq4OXXY,1554
|
|
22
|
+
learning_loop_node/detector/detector_node.py,sha256=AB-uXNPGxRbQwjLIN35uyk1Qww6T1bF4fqWSbwV6F1A,37395
|
|
23
23
|
learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
|
|
24
24
|
learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
|
|
25
25
|
learning_loop_node/detector/outbox.py,sha256=mtMxdcComjRyM6qc-O1fBv9ABCau4-oCCV442hMaryQ,12351
|
|
@@ -42,7 +42,7 @@ learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxk
|
|
|
42
42
|
learning_loop_node/enums/trainer.py,sha256=VdamD-k6Q8kMm_cwPA8iaEUfZvmTxLfsBHQpEi43Ahk,849
|
|
43
43
|
learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
44
|
learning_loop_node/helpers/background_tasks.py,sha256=sbbS8JO1ls0ZCbNW68KOT9QLOFyFoJRXwwAq78iOmoA,3256
|
|
45
|
-
learning_loop_node/helpers/entrypoint.py,sha256=
|
|
45
|
+
learning_loop_node/helpers/entrypoint.py,sha256=ZPjbH-mM6quvnZ8-L8GxQaINX8D5qlQ-K77URSFcmc0,4540
|
|
46
46
|
learning_loop_node/helpers/environment_reader.py,sha256=zsoFysxNxBCOJzUwX4Pbm3ftYhxx5zsbOHQfD9VcfvA,1937
|
|
47
47
|
learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
|
|
48
48
|
learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
|
|
@@ -52,20 +52,21 @@ learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
|
|
|
52
52
|
learning_loop_node/trainer/batch_size.py,sha256=LjA-zyY4hdnSMy6ZsdQOhWbmd5H_DI-9LG3yFInv04E,5476
|
|
53
53
|
learning_loop_node/trainer/cuda.py,sha256=ehpfWqPBCVFu06SnGsjmWbSDRJ8sZc-L_nCtemRLUOU,12567
|
|
54
54
|
learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
|
|
55
|
-
learning_loop_node/trainer/exceptions.py,sha256=
|
|
55
|
+
learning_loop_node/trainer/exceptions.py,sha256=7PeJ1wXPvf7NYBrEkPNvZvTTGTBXZ9zIGE5tE-tWPu8,1435
|
|
56
56
|
learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
|
|
57
|
+
learning_loop_node/trainer/hyperparameters.py,sha256=WtVh3PThIZs0Oe6IGQF73-WfdtHpf6wL6tsc0r9iUAY,8623
|
|
57
58
|
learning_loop_node/trainer/io_helpers.py,sha256=fmSh2-9sEfBavYxfa_zT4FfZMByT3KxDbA3O57I8iBY,7834
|
|
58
59
|
learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
|
|
59
60
|
learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
|
|
60
61
|
learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
|
|
61
62
|
learning_loop_node/trainer/trainer_logic.py,sha256=x15bEy7uTfaGGHGYRGUl2olMeL0x0QW0vlu8nXZOaGg,9769
|
|
62
|
-
learning_loop_node/trainer/trainer_logic_generic.py,sha256=
|
|
63
|
-
learning_loop_node/trainer/trainer_node.py,sha256=
|
|
63
|
+
learning_loop_node/trainer/trainer_logic_generic.py,sha256=_vHtOfgZInGcEwUHZFTcZhiAp7rui4DwTd8GxkH1Wf8,33682
|
|
64
|
+
learning_loop_node/trainer/trainer_node.py,sha256=viYXQvUnwWfFjMKsh5Y-uWPCY2I2HwQh92w7bnBa0XY,5830
|
|
64
65
|
learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
|
|
65
|
-
learning_loop_node/trainer/validation_set.py,sha256=
|
|
66
|
+
learning_loop_node/trainer/validation_set.py,sha256=j0KAUP8CN4NpudL180xtT1xUQV0bCfxw1tMlPdb9l4Y,3363
|
|
66
67
|
learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
67
68
|
learning_loop_node/trainer/rest/backdoor_controls.py,sha256=bCjrQvqtgd2DtyYNgftkcDe9AFHExFLtfdW_jISLwuk,5026
|
|
68
|
-
learning_loop_node-0.
|
|
69
|
-
learning_loop_node-0.
|
|
70
|
-
learning_loop_node-0.
|
|
71
|
-
learning_loop_node-0.
|
|
69
|
+
learning_loop_node-0.28.0.dist-info/METADATA,sha256=9T_Gxp-5PA9CCPsmbBPWy5jBjmDElOaUbDhY1Qfeors,20629
|
|
70
|
+
learning_loop_node-0.28.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
71
|
+
learning_loop_node-0.28.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
|
|
72
|
+
learning_loop_node-0.28.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|