learning-loop-node 0.27.2__py3-none-any.whl → 0.29.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.
@@ -26,10 +26,11 @@ from .general import (
26
26
  NodeState,
27
27
  NodeStatus,
28
28
  )
29
- from .image_metadata import ImageMetadata, ImagesMetadata
29
+ from .image_metadata import CaptureMetadata, 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,
@@ -43,8 +44,8 @@ __all__ = [
43
44
  'BoxDetection', 'ClassificationDetection', 'ImageMetadata', 'Observation', 'Point', 'PointDetection',
44
45
  'SegmentationDetection', 'Shape', 'Detections',
45
46
  'AnnotationNodeStatus', 'Category', 'Context', 'DetectorStatus', 'ErrorConfiguration',
46
- 'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata',
47
+ 'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata', 'CaptureMetadata',
47
48
  'SocketResponse',
48
- 'Errors', 'PretrainedModel', 'Training',
49
+ 'Errors', 'Hyperparameter', 'PretrainedModel', 'Training',
49
50
  'TrainingError', 'TrainingOut', 'TrainingStateData', 'TrainingStatus',
50
51
  ]
@@ -56,3 +56,16 @@ class ImageMetadata():
56
56
  @dataclass(**KWONLY_SLOTS)
57
57
  class ImagesMetadata():
58
58
  items: List[ImageMetadata] = field(default_factory=list, metadata={'description': 'List of image metadata'})
59
+
60
+
61
+ @dataclass(**KWONLY_SLOTS)
62
+ class CaptureMetadata:
63
+ """Where an image comes from and how it is tagged."""
64
+ camera_id: str | None = field(default=None, metadata={
65
+ 'description': 'Camera the image was taken with; selects the history of the relevance filter'})
66
+ tags: list[str] = field(default_factory=list, metadata={
67
+ 'description': 'Tags of the image'})
68
+ source: str | None = field(default=None, metadata={
69
+ 'description': 'Source of the image'})
70
+ creation_date: str | None = field(default=None, metadata={
71
+ 'description': 'Creation date of the image'})
@@ -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
 
@@ -14,7 +14,12 @@ import aiofiles # type: ignore
14
14
  from .data_classes import Context
15
15
  from .helpers.misc import create_resource_paths, create_task, is_valid_image, is_valid_uuid4
16
16
  from .loop_communication import LoopCommunicator
17
- from .trainer.exceptions import CriticalError, ModelUploadFailedError
17
+ from .trainer.exceptions import (
18
+ CriticalError,
19
+ ModelUploadFailedError,
20
+ TrainingResultsNotAnsweredError,
21
+ TrainingResultsUploadFailedError,
22
+ )
18
23
 
19
24
  logger = logging.getLogger(__name__)
20
25
 
@@ -207,6 +212,23 @@ class DataExchanger():
207
212
  training_number, mformat, uploaded_model)
208
213
  return uploaded_model['id']
209
214
 
215
+ async def upload_training_results(self, context: Context, training_number: int, part: str, zip_path: str) -> None:
216
+ """Store one part of a training's results beside its latest model; a part sent again replaces the stored one.
217
+
218
+ :raise TrainingResultsUploadFailedError: If the loop answered with a client error other than 408.
219
+ :raise TrainingResultsNotAnsweredError: If the loop answered 408 or a server error.
220
+ """
221
+ path = (f'/{context.organization}/projects/{context.project}/trainings/{training_number}'
222
+ f'/training_results/{part}')
223
+ response = await self.loop_communicator.put(path, files=[zip_path], timeout=60*10)
224
+ if response.status_code == HTTPStatus.REQUEST_TIMEOUT or response.status_code >= 500:
225
+ raise TrainingResultsNotAnsweredError(f'The loop did not answer the upload of the {part} results of '
226
+ f'training {training_number}: {response.status_code} {response.text}')
227
+ if response.status_code != HTTPStatus.OK:
228
+ raise TrainingResultsUploadFailedError(f'The loop did not store the {part} results of training '
229
+ f'{training_number}: {response.status_code} {response.text}')
230
+ logger.info('Uploaded the %s results of training %s', part, training_number)
231
+
210
232
 
211
233
  def _rejects_upload(status_code: int) -> bool:
212
234
  """A client error other than 408 and 429 answers the same however often the upload is sent."""
@@ -19,6 +19,7 @@ from socketio import AsyncClient
19
19
 
20
20
  from ..data_classes import (
21
21
  AboutResponse,
22
+ CaptureMetadata,
22
23
  Context,
23
24
  DetectorStatus,
24
25
  ImageMetadata,
@@ -272,10 +273,16 @@ class DetectorNode(Node):
272
273
 
273
274
  Data dict follows the schema of the detect endpoint,
274
275
  but 'images' is a list of image dicts.
276
+ Each image dict may also carry its own:
277
+ - camera_id: replaces the camera_id of the batch
278
+ - tags: added to the tags of the batch
279
+ - source: replaces the source of the batch
280
+ - creation_date: replaces the creation_date of the batch
275
281
  """
276
282
  try:
277
283
  images_data = data['images']
278
284
  images = [image_from_dict(image) for image in images_data]
285
+ capture_metadata = [_capture_metadata(data, image) for image in images_data]
279
286
  except Exception:
280
287
  self.log.exception('could not parse images from socketio')
281
288
  return {'error': 'could not parse images from data'}
@@ -283,11 +290,8 @@ class DetectorNode(Node):
283
290
  try:
284
291
  det = await self.get_batch_detections(
285
292
  images=images,
286
- tags=data.get('tags', []),
287
- camera_id=data.get('camera_id', None),
288
- source=data.get('source', None),
293
+ capture_metadata=capture_metadata,
289
294
  autoupload=data.get('autoupload', 'filtered'),
290
- creation_date=data.get('creation_date', None),
291
295
  )
292
296
  return jsonable_encoder(asdict(det))
293
297
  except DetectorUnavailableError as e:
@@ -638,35 +642,17 @@ class DetectorNode(Node):
638
642
  array = await _pixel_array(image)
639
643
  async with self.detection_lock:
640
644
  detector = _unwrap_detector(self._detector)
641
- metadata = await run.io_bound(detector.logic.evaluate, array)
642
-
643
- metadata.tags.extend(tags)
644
- metadata.source = source
645
- metadata.created = creation_date
646
-
647
- fix_shape_detections(metadata)
648
- n_bo, n_cl = len(metadata.box_detections), len(metadata.classification_detections)
649
- n_po, n_se = len(metadata.point_detections), len(metadata.segmentation_detections)
650
- self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
645
+ detections = await run.io_bound(detector.logic.evaluate, array)
651
646
 
652
- if autoupload == 'filtered':
653
- background_tasks.create(self.relevance_filter.may_upload_detections(metadata, camera_id, image))
654
- elif autoupload == 'all':
655
- background_tasks.create(self.outbox.save(image, metadata))
656
- elif autoupload == 'disabled':
657
- pass
658
- else:
659
- self.log.error('unknown autoupload value %s', autoupload)
660
- return metadata
647
+ capture = CaptureMetadata(camera_id=camera_id, tags=tags, source=source, creation_date=creation_date)
648
+ self._finish_detection(detections, image, capture, autoupload)
649
+ return detections
661
650
 
662
651
  async def get_batch_detections(self,
663
652
  images: Sequence[ImageData],
664
- tags: List[str],
653
+ capture_metadata: Sequence[CaptureMetadata],
665
654
  *,
666
- camera_id: Optional[str] = None,
667
- source: Optional[str] = None,
668
- autoupload: str = 'filtered',
669
- creation_date: Optional[str] = None) -> ImagesMetadata:
655
+ autoupload: str = 'filtered') -> ImagesMetadata:
670
656
  """
671
657
  Processing function for the detector node when a batch inference is requested via SocketIO.
672
658
 
@@ -674,35 +660,45 @@ class DetectorNode(Node):
674
660
  cares about uploading to the loop and returns the detections as a list of ImageMetadata.
675
661
  Each image is a pixel array or JPEG bytes; JPEG bytes are decoded for the inference
676
662
  and stored as they are if the image is uploaded.
663
+ `capture_metadata` holds the camera, tags, source and creation date of each image.
677
664
  Raises exception if no model is loaded.
678
665
  """
666
+ if len(capture_metadata) != len(images):
667
+ raise ValueError(f'got {len(capture_metadata)} capture metadata for {len(images)} images')
679
668
  _unwrap_detector(self._detector) # fails fast, before the decode
680
669
  arrays = await asyncio.gather(*(_pixel_array(image) for image in images))
681
670
  async with self.detection_lock:
682
671
  detector = _unwrap_detector(self._detector)
683
672
  all_detections = await run.io_bound(detector.logic.batch_evaluate, arrays)
684
673
 
685
- for metadata in all_detections.items:
686
- metadata.tags.extend(tags)
687
- metadata.source = source
688
- metadata.created = creation_date
689
-
690
- for detections, image in zip(all_detections.items, images):
691
- fix_shape_detections(detections)
692
- n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
693
- n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
694
- self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
695
-
696
- if autoupload == 'filtered':
697
- background_tasks.create(self.relevance_filter.may_upload_detections(detections, camera_id, image))
698
- elif autoupload == 'all':
699
- background_tasks.create(self.outbox.save(image, detections))
700
- elif autoupload == 'disabled':
701
- pass
702
- else:
703
- self.log.error('unknown autoupload value %s', autoupload)
674
+ for detections, image, capture in zip(all_detections.items, images, capture_metadata):
675
+ self._finish_detection(detections, image, capture, autoupload)
704
676
  return all_detections
705
677
 
678
+ def _finish_detection(self,
679
+ detections: ImageMetadata,
680
+ image: ImageData,
681
+ capture: CaptureMetadata,
682
+ autoupload: str) -> None:
683
+ detections.tags.extend(capture.tags)
684
+ detections.source = capture.source
685
+ detections.created = capture.creation_date
686
+
687
+ fix_shape_detections(detections)
688
+ n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
689
+ n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
690
+ self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
691
+
692
+ if autoupload == 'filtered':
693
+ background_tasks.create(
694
+ self.relevance_filter.may_upload_detections(detections, capture.camera_id, image))
695
+ elif autoupload == 'all':
696
+ background_tasks.create(self.outbox.save(image, detections))
697
+ elif autoupload == 'disabled':
698
+ pass
699
+ else:
700
+ self.log.error('unknown autoupload value %s', autoupload)
701
+
706
702
  async def upload_images(
707
703
  self, *,
708
704
  images: Sequence[ImageData],
@@ -785,6 +781,23 @@ async def _pixel_array(image: ImageData) -> np.ndarray:
785
781
  return await run.io_bound(jpg_bytes_to_numpy_array, image)
786
782
 
787
783
 
784
+ def _capture_metadata(batch_data: dict, image_data: dict) -> CaptureMetadata:
785
+ """The values of one image dict of a batch: its own where it sets them, the batch's otherwise, and both tags."""
786
+ image_tags = image_data.get('tags') or []
787
+ if not isinstance(image_tags, list):
788
+ raise ValueError(f'tags of an image must be a list, got {type(image_tags).__name__}')
789
+ return CaptureMetadata(camera_id=_own_or_batch('camera_id', image_data, batch_data),
790
+ tags=[*batch_data.get('tags', []), *image_tags],
791
+ source=_own_or_batch('source', image_data, batch_data),
792
+ creation_date=_own_or_batch('creation_date', image_data, batch_data))
793
+
794
+
795
+ def _own_or_batch(key: str, image_data: dict, batch_data: dict) -> str | None:
796
+ """`None` counts as not set, so an image cannot clear a value of the batch."""
797
+ own = image_data.get(key)
798
+ return own if own is not None else batch_data.get(key)
799
+
800
+
788
801
  def _unwrap_detector(state: '_DetectorState') -> _ActiveDetector:
789
802
  """Return the active detector or raise DetectorUnavailableError."""
790
803
  match state:
@@ -11,10 +11,29 @@ class ModelUploadFailedError(Exception):
11
11
  """
12
12
 
13
13
 
14
+ class TrainingResultsUploadFailedError(Exception):
15
+ """Raised when the loop answered the upload of a part of a training's results with a client error other than 408.
16
+ The answer is final: sending the part again would get the same answer.
17
+ """
18
+
19
+
20
+ class TrainingResultsNotAnsweredError(Exception):
21
+ """Raised when the loop gave up waiting for a part of a training's results (408) or answered with a server error;
22
+ the part may be sent again.
23
+ """
24
+
25
+
14
26
  class TrainingRefusedError(Exception):
15
27
  """Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
16
28
 
17
29
 
30
+ class HyperparameterMismatchError(Exception):
31
+ """Raised when a training's hyperparameters do not fit the trainer's declaration; nothing was set up.
32
+
33
+ Unlike a refusal, the training would fail on any trainer of the same declaration.
34
+ """
35
+
36
+
18
37
  class TrainingSlotTakenError(TrainingRefusedError):
19
38
  """Raised when a sibling node holds the training slot."""
20
39
 
@@ -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
@@ -26,7 +26,6 @@ class TrainerLogic(TrainerLogicGeneric):
26
26
  self._detection_progress: Optional[float] = None
27
27
  self._executor: Optional[Executor] = None
28
28
  self._process_started: bool = False
29
- self._stop_requested: bool = False
30
29
  self.start_training_task: Optional[Coroutine] = None
31
30
  self.inference_batch_size = self._environment_vars.inference_batch_size
32
31
 
@@ -6,6 +6,7 @@ import sys
6
6
  import time
7
7
  from abc import ABC, abstractmethod
8
8
  from dataclasses import asdict
9
+ from pathlib import Path
9
10
  from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, List, Optional
10
11
 
11
12
  from fastapi.encoders import jsonable_encoder
@@ -13,6 +14,7 @@ from fastapi.encoders import jsonable_encoder
13
14
  from ..data_classes import (
14
15
  Context,
15
16
  Errors,
17
+ Hyperparameter,
16
18
  PretrainedModel,
17
19
  Training,
18
20
  TrainingOut,
@@ -25,8 +27,10 @@ from ..helpers.misc import (
25
27
  delete_all_training_folders,
26
28
  is_valid_uuid4,
27
29
  )
30
+ from . import training_results
28
31
  from .downloader import TrainingsDownloader
29
- from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
32
+ from .exceptions import CriticalError, HyperparameterMismatchError, NodeNeedsRestartError, TrainingRefusedError
33
+ from .hyperparameters import resolve, warn_about_undeclared
30
34
  from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
31
35
  from .training_slot import TrainingSlot, training_slot_from_env
32
36
  from .validation_set import assign_validation_set
@@ -55,6 +59,7 @@ class TrainerLogicGeneric(ABC):
55
59
  self.model_format: str = model_format
56
60
  self.errors = Errors()
57
61
  self._state_failures: Dict[str, int] = {} # failed attempts in a row, by error key
62
+ self._stop_requested: bool = False # set by a trainer that stopped its training process; not kept on restart
58
63
 
59
64
  self.training_task: Optional[asyncio.Task] = None
60
65
  self.shutdown_event: asyncio.Event = asyncio.Event()
@@ -186,6 +191,12 @@ class TrainerLogicGeneric(ABC):
186
191
  """
187
192
  raise NotImplementedError
188
193
 
194
+ @property
195
+ @abstractmethod
196
+ def provided_hyperparameters(self) -> list[Hyperparameter]:
197
+ """Returns the hyperparameters this trainer accepts; an empty list leaves them unchecked."""
198
+ raise NotImplementedError
199
+
189
200
  # ---------------------------------------- METHODS ----------------------------------------
190
201
 
191
202
  # NOTE: Trainings are started by the Learning Loop via the begin_training event
@@ -204,6 +215,7 @@ class TrainerLogicGeneric(ABC):
204
215
  progress=self.general_progress)
205
216
 
206
217
  status.pretrained_models = self.provided_pretrained_models
218
+ status.hyperparameters = self.provided_hyperparameters
207
219
  status.architecture = self.model_architecture
208
220
 
209
221
  if self._training:
@@ -224,9 +236,18 @@ class TrainerLogicGeneric(ABC):
224
236
 
225
237
  def _init_from_last_training(self) -> None:
226
238
  """Initializes a new training object from the last training saved on disc via last_training_io.
239
+
240
+ The hyperparameters are resolved again, since the training may have been saved before this trainer declared
241
+ them. If one no longer fits, the training continues with the values it was saved with, since it already runs
242
+ with them.
227
243
  """
228
244
  self._training = self.last_training_io.load()
229
245
  assert self._training is not None and self._training.training_folder is not None, 'could not restore training folder'
246
+ try:
247
+ self._training.hyperparameters = resolve(self.provided_hyperparameters, self._training.hyperparameters)
248
+ except CriticalError as e:
249
+ logger.warning('restored training does not fit the declared hyperparameters, continuing with the saved '
250
+ 'values: %s', e)
230
251
  logger.info('restored training: \n%s', self._training)
231
252
  self._active_training_io = ActiveTrainingIO(
232
253
  self._training.training_folder, self.node.loop_communicator, self._training.context)
@@ -234,13 +255,15 @@ class TrainerLogicGeneric(ABC):
234
255
  async def begin_training(self, organization: str, project: str, training_config: Dict) -> None:
235
256
  """Called on `begin_training` event from the Learning Loop.
236
257
 
237
- The training slot is taken before anything is set up, so a refused training leaves the
238
- previous training folders in place.
258
+ The hyperparameters are resolved and the training slot is taken before anything is set up, so a refused
259
+ training leaves the previous training folders in place.
239
260
 
240
261
  :raise TrainingRefusedError: if a training is active or a sibling node holds the training slot.
262
+ :raise HyperparameterMismatchError: if a hyperparameter does not fit its declaration.
241
263
  """
242
264
  if self.training_active:
243
265
  raise TrainingRefusedError(f'training {self.training.id} is still active')
266
+ training_config = self._with_resolved_hyperparameters(training_config)
244
267
  context = Context(organization=organization, project=project)
245
268
  self.training_slot.claim(self._slot_claim(context, training_config.get('training_number')))
246
269
  try:
@@ -250,6 +273,24 @@ class TrainerLogicGeneric(ABC):
250
273
  raise
251
274
  self._begin_training_task()
252
275
 
276
+ def _with_resolved_hyperparameters(self, training_config: dict) -> dict:
277
+ """Returns ``training_config`` with its hyperparameters resolved against the declared ones.
278
+
279
+ A config without a hyperparameters dict is returned as it is, for ``Training.generate_training`` to reject.
280
+
281
+ :raise HyperparameterMismatchError: if a hyperparameter does not fit its declaration.
282
+ """
283
+ declared = self.provided_hyperparameters
284
+ received = training_config.get('hyperparameters')
285
+ if not declared or not isinstance(received, dict):
286
+ return training_config
287
+ try:
288
+ resolved = resolve(declared, received)
289
+ except CriticalError as e:
290
+ raise HyperparameterMismatchError(str(e)) from e
291
+ warn_about_undeclared(declared, received)
292
+ return {**training_config, 'hyperparameters': resolved}
293
+
253
294
  def _begin_training_task(self) -> None:
254
295
  # NOTE: Task object is used to potentially cancel the task
255
296
  self.training_task = asyncio.get_event_loop().create_task(self._run())
@@ -378,7 +419,11 @@ class TrainerLogicGeneric(ABC):
378
419
  self.last_training_io.save(self.training)
379
420
 
380
421
  async def _prepare(self) -> None:
381
- """Downloads images to the images_folder and saves annotations to training.data.image_data."""
422
+ """Downloads images to the images_folder and saves annotations to training.data.image_data.
423
+ Moves part of the train images to `val` if `compute_validation_set` asks for it (see `validation_set`).
424
+ A malformed `upload_examples` or `upload_logs` ends the training here, before it has run.
425
+ """
426
+ training_results.check_hyperparameters(self.training.hyperparameters)
382
427
  self.node.data_exchanger.set_context(self.training.context)
383
428
  downloader = TrainingsDownloader(self.node.data_exchanger)
384
429
  image_data, skipped_image_count = await downloader.download_training_data(self.training.images_folder)
@@ -451,11 +496,16 @@ class TrainerLogicGeneric(ABC):
451
496
  self.errors.reset(error_key)
452
497
 
453
498
  async def _upload_model(self) -> None:
454
- """Uploads the latest model to the Learning Loop.
499
+ """Uploads the latest model and then its training results to the Learning Loop.
500
+ The results never fail the state (see `training_results`).
455
501
  """
456
502
  new_model_uuid = await self._upload_model_return_new_model_uuid(self.training.context)
457
503
  logger.info('Successfully uploaded model and received new model id: %s', new_model_uuid)
458
504
  self.training.model_uuid_for_detecting = new_model_uuid
505
+ await training_results.upload_results(self.training, model_uuid=new_model_uuid, node_name=self.node.name,
506
+ node_uuid=self.node.uuid, get_results=self._get_training_results,
507
+ stop_requested=self._stop_requested,
508
+ data_exchanger=self.node.data_exchanger)
459
509
 
460
510
  async def _upload_model_return_new_model_uuid(self, context: Context) -> str:
461
511
  """Upload model files, usually pytorch model (.pt) hyp.yaml and the converted .wts file.
@@ -620,6 +670,14 @@ class TrainerLogicGeneric(ABC):
620
670
  """
621
671
  raise NotImplementedError
622
672
 
673
+ def _get_training_results(self, training_folder: Path) -> dict[str, list[Path]]:
674
+ """Called when the model is uploaded. Returns the result files of the training by part, stored beside the model.
675
+ The parts are `core` (always uploaded) and `examples` and `logs` (uploaded only if `upload_examples` and
676
+ `upload_logs` are true); see `training_results`. Directories go in as a whole. Name only results, never the
677
+ dataset. Default: no results, and nothing is uploaded.
678
+ """
679
+ return {}
680
+
623
681
  def _get_model_metadata(self) -> dict[str, Any]:
624
682
  """Return the values the trainer determined for this model, to be merged into its model.json.
625
683
 
@@ -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
@@ -0,0 +1,187 @@
1
+ """The results of a training — metrics, curves, logs — uploaded to the loop beside its model.
2
+
3
+ A trainer names its result files in `_get_training_results`, grouped into the parts `core`, `examples` and
4
+ `logs`. `upload_results` sends them once the model is uploaded and never fails the training: a part the loop
5
+ answered is done, a part that got no answer or a server error is sent up to `MAX_ATTEMPTS` times, and
6
+ anything else that goes wrong is logged and skips the part. `core` is always uploaded; `examples` and `logs`
7
+ only if the training's `upload_examples` and `upload_logs` hyperparameters ask for them. Each part goes up as
8
+ one zip that keeps the paths inside the training folder, and `core` additionally holds `meta.json`, where
9
+ the results come from.
10
+ """
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import zipfile
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import datetime, timezone
18
+ from importlib.metadata import PackageNotFoundError, version
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from ..data_classes import Training
25
+ from ..data_exchanger import DataExchanger
26
+ from .exceptions import CriticalError, TrainingResultsNotAnsweredError, TrainingResultsUploadFailedError
27
+ from .validation_set import bool_hyperparameter
28
+
29
+ CORE = 'core'
30
+ EXAMPLES = 'examples'
31
+ LOGS = 'logs'
32
+ PARTS = (CORE, EXAMPLES, LOGS)
33
+
34
+ UPLOAD_EXAMPLES = 'upload_examples'
35
+ UPLOAD_LOGS = 'upload_logs'
36
+ OPTIONAL_PARTS = {EXAMPLES: UPLOAD_EXAMPLES, LOGS: UPLOAD_LOGS}
37
+ """The parts uploaded only if the hyperparameter they map to is true."""
38
+
39
+ META_FILE = 'meta.json'
40
+ RESULTS_FOLDER = 'training_results'
41
+ """Inside the training folder: the zips and the record of the parts that are done."""
42
+ DONE_FILE = 'uploaded_parts.txt'
43
+
44
+ MAX_ATTEMPTS = 3
45
+ RETRY_DELAY = 5 # seconds before the second attempt, doubled before each further one
46
+
47
+ COMPRESSED_SUFFIXES = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp4', '.zip', '.gz', '.tgz', '.xz', '.7z'}
48
+ """Files stored in a zip as they are; compressing them again costs time and saves almost nothing."""
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def check_hyperparameters(hyperparameters: Mapping[str, Any]) -> None:
54
+ """Raise a CriticalError if `upload_examples` or `upload_logs` is set to anything but a boolean."""
55
+ for name in OPTIONAL_PARTS.values():
56
+ bool_hyperparameter(hyperparameters, name)
57
+
58
+
59
+ async def upload_results(training: Training, *, model_uuid: str, node_name: str, node_uuid: str,
60
+ get_results: Callable[[Path], Mapping[str, list[Path]]], stop_requested: bool,
61
+ data_exchanger: DataExchanger) -> None:
62
+ """Upload the parts of `get_results(training_folder)` the training's hyperparameters ask for; never raises.
63
+
64
+ A part is recorded as done once it is answered or skipped, so a second call sends only the parts left.
65
+ """
66
+ training_folder = Path(training.training_folder)
67
+ results_folder = training_folder / RESULTS_FOLDER
68
+ try:
69
+ results = get_results(training_folder)
70
+ parts = _parts_to_upload(results, training.hyperparameters)
71
+ meta_content = _meta(training, model_uuid=model_uuid, node_name=node_name, node_uuid=node_uuid,
72
+ stop_requested=stop_requested)
73
+ done = _load_done(results_folder)
74
+ except Exception:
75
+ logger.exception('Skipping the results of training %s', training.training_number)
76
+ return
77
+ for part in [p for p in parts if p not in done]:
78
+ try:
79
+ await _upload_part(training, part, results[part], meta_content if part == CORE else None, data_exchanger)
80
+ except TrainingResultsUploadFailedError as e:
81
+ logger.warning('Skipping the %s results of training %s: %s', part, training.training_number, e)
82
+ except Exception:
83
+ logger.exception('Skipping the %s results of training %s', part, training.training_number)
84
+ done.append(part)
85
+ try:
86
+ _save_done(results_folder, done)
87
+ except OSError:
88
+ logger.exception('Could not record the %s results of training %s as done', part, training.training_number)
89
+
90
+
91
+ def _parts_to_upload(results: Mapping[str, list[Path]], hyperparameters: Mapping[str, Any]) -> list[str]:
92
+ """The parts of `results` to upload, in upload order: `core` first, the optional ones only if asked for.
93
+
94
+ A part not in `PARTS`, or one whose hyperparameter is malformed, is logged and left out.
95
+ """
96
+ parts = []
97
+ for part in results:
98
+ if part not in PARTS:
99
+ logger.warning('Leaving out the training results part %r, which is none of %s', part, ', '.join(PARTS))
100
+ continue
101
+ if part in OPTIONAL_PARTS:
102
+ try:
103
+ if not bool_hyperparameter(hyperparameters, OPTIONAL_PARTS[part]):
104
+ continue
105
+ except CriticalError as e:
106
+ logger.error('Leaving out the %s results: %s', part, e)
107
+ continue
108
+ parts.append(part)
109
+ return sorted(parts, key=lambda part: part != CORE)
110
+
111
+
112
+ def _meta(training: Training, *, model_uuid: str, node_name: str, node_uuid: str,
113
+ stop_requested: bool) -> dict[str, Any]:
114
+ """Where the results come from, so they explain themselves without the loop's database."""
115
+ return {
116
+ 'node_name': node_name,
117
+ 'node_uuid': node_uuid,
118
+ 'organization': training.context.organization,
119
+ 'project': training.context.project,
120
+ 'training_number': training.training_number,
121
+ 'model_uuid': model_uuid,
122
+ 'start_time': datetime.fromtimestamp(training.start_time, timezone.utc).isoformat(),
123
+ 'uploaded_at': datetime.now(timezone.utc).isoformat(),
124
+ 'stop_requested': stop_requested,
125
+ 'learning_loop_node_version': _library_version(),
126
+ 'hyperparameters': dict(training.hyperparameters),
127
+ }
128
+
129
+
130
+ async def _upload_part(training: Training, part: str, files: list[Path], part_meta: dict[str, Any] | None,
131
+ data_exchanger: DataExchanger) -> None:
132
+ """Zip and send one part; send it again while it gets no answer, up to `MAX_ATTEMPTS` times."""
133
+ training_folder = Path(training.training_folder)
134
+ zip_path = training_folder / RESULTS_FOLDER / f'{part}.zip'
135
+ await asyncio.to_thread(_write_zip, files, training_folder, zip_path, part_meta)
136
+ for attempt in range(1, MAX_ATTEMPTS + 1):
137
+ try:
138
+ await data_exchanger.upload_training_results(training.context, training.training_number, part,
139
+ str(zip_path))
140
+ return
141
+ except (TrainingResultsNotAnsweredError, httpx.TransportError):
142
+ if attempt == MAX_ATTEMPTS:
143
+ raise
144
+ await asyncio.sleep(RETRY_DELAY * 2 ** (attempt - 1))
145
+
146
+
147
+ def _write_zip(files: list[Path], training_folder: Path, zip_path: Path, meta: dict[str, Any] | None) -> None:
148
+ """Zip `files` into `zip_path`; directories go in as a whole, except anything in the folder of `zip_path`.
149
+
150
+ A path inside `training_folder` keeps its path relative to it, any other its path relative to the parent of
151
+ the file or directory named. Files in `COMPRESSED_SUFFIXES` are stored, everything else compressed.
152
+ A file that does not exist is logged and left out.
153
+ """
154
+ results_folder = zip_path.parent
155
+ results_folder.mkdir(parents=True, exist_ok=True)
156
+ partial = zip_path.with_name(f'{zip_path.name}.partial')
157
+ with zipfile.ZipFile(partial, 'w', compression=zipfile.ZIP_DEFLATED) as zipf:
158
+ for file in files:
159
+ if not file.exists():
160
+ logger.warning('training result %s does not exist, leaving it out', file)
161
+ continue
162
+ root = training_folder if file.is_relative_to(training_folder) else file.parent
163
+ for path in sorted(file.rglob('*')) if file.is_dir() else [file]:
164
+ if path.is_file() and not path.is_relative_to(results_folder):
165
+ stored = path.suffix.lower() in COMPRESSED_SUFFIXES
166
+ zipf.write(path, str(path.relative_to(root)),
167
+ compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED)
168
+ if meta is not None:
169
+ zipf.writestr(META_FILE, json.dumps(meta, indent=1))
170
+ os.replace(partial, zip_path)
171
+
172
+
173
+ def _load_done(results_folder: Path) -> list[str]:
174
+ path = results_folder / DONE_FILE
175
+ return path.read_text().split() if path.exists() else []
176
+
177
+
178
+ def _save_done(results_folder: Path, done: list[str]) -> None:
179
+ results_folder.mkdir(parents=True, exist_ok=True)
180
+ (results_folder / DONE_FILE).write_text('\n'.join(done))
181
+
182
+
183
+ def _library_version() -> str | None:
184
+ try:
185
+ return version('learning_loop_node')
186
+ except PackageNotFoundError:
187
+ return None
@@ -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,14 +29,25 @@ 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
 
34
41
  def compute_validation_set(hyperparameters: Mapping[str, Any]) -> bool:
35
42
  """Whether `compute_validation_set` is set, defaulting to false; anything but a boolean is a CriticalError."""
36
- value = hyperparameters.get(COMPUTE_VALIDATION_SET, False)
43
+ return bool_hyperparameter(hyperparameters, COMPUTE_VALIDATION_SET)
44
+
45
+
46
+ def bool_hyperparameter(hyperparameters: Mapping[str, Any], name: str) -> bool:
47
+ """Whether the hyperparameter `name` is set, defaulting to false; anything but a boolean is a CriticalError."""
48
+ value = hyperparameters.get(name, False)
37
49
  if not isinstance(value, bool):
38
- raise CriticalError(f"'{COMPUTE_VALIDATION_SET}' must be true or false, got {value!r}")
50
+ raise CriticalError(f"'{name}' must be true or false, got {value!r}")
39
51
  return value
40
52
 
41
53
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.27.2
3
+ Version: 0.29.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
@@ -129,6 +129,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
129
129
 
130
130
  - `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
131
131
 
132
+ The entries `camera_id`, `tags`, `source` and `creation_date` apply to every image of the batch. An image data dictionary may also carry its own: its `tags` are added to those of the batch, while its `camera_id`, `source` and `creation_date` replace those of the batch. This way a batch can hold images of several cameras, each filtered and uploaded as its own camera's.
133
+
132
134
  Sending a camera's JPEG as it is (`format: 'jpeg'`) saves the bandwidth of a raw pixel array and spares the detector a re-encode for the upload.
133
135
 
134
136
  Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
@@ -256,6 +258,17 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
256
258
 
257
259
  - if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
258
260
 
261
+ ### Training results
262
+
263
+ Together with its model, a trainer uploads the results of the training to the Learning Loop, e.g. the
264
+ metrics per epoch, the evaluation curves and the confusion matrix. The trainer decides which files
265
+ they are, grouped into the parts `core`, `examples` and `logs`; a trainer that names none uploads
266
+ only its model. `core` is always uploaded, the examples and the raw training log only if the
267
+ training's `upload_examples` and `upload_logs` hyperparameters are true (both default to false).
268
+ The results are sent right after the model and never stop the training: results the Learning Loop
269
+ does not store, e.g. beyond the 5 GiB it keeps per training, are logged and skipped, and a part
270
+ that gets no answer or a server error is sent up to three times.
271
+
259
272
  ### Several trainers on one GPU
260
273
 
261
274
  Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
@@ -1,5 +1,5 @@
1
1
  learning_loop_node/__init__.py,sha256=LzNoXBrcL6yJTyisuwJokZ9LTiYQwY3yRY3tmy83FlI,410
2
- learning_loop_node/data_exchanger.py,sha256=Rlql4WSDgF6uUivH77deoIl2vnTKL1rlEhQmAUmJ9IU,10642
2
+ learning_loop_node/data_exchanger.py,sha256=Ap5m_Bd_fHKz82YQiLQphAu5mwJ8pc9VllcinqTRrLo,12099
3
3
  learning_loop_node/globals.py,sha256=a50Qg4z2205Zcw4Cs8z417kn1x8s78o2FJfZB9-ZZI4,211
4
4
  learning_loop_node/loop_communication.py,sha256=d1tJZ9T_y22FrEq1XzvidgH6d-byGxFq3ovLvPBS4FA,7611
5
5
  learning_loop_node/node.py,sha256=CABqe-sqUlcwi9CYVXBkrfxh0Xgtzj3AFLQcupSX-Ms,12158
@@ -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=_2dxfVkkI9kNM955Y7ZE8RLgfpegZpX5tXkkPP_n6Fo,1354
11
+ learning_loop_node/data_classes/__init__.py,sha256=1mrGtrDCxILDwmPcbLgjqYp0cg7ObKusFR3BDoSMQIQ,1428
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
15
  learning_loop_node/data_classes/general.py,sha256=0p6RhPOEVr_vBoG-TySTUKhjT2_AS9Jb8jEqSkg5ncc,7829
16
- learning_loop_node/data_classes/image_metadata.py,sha256=QPLJ4gcVj33xaaCFF1n5e8G6higOJ6iIKy3YhGZMfkw,2459
16
+ learning_loop_node/data_classes/image_metadata.py,sha256=wRkDRHAhuiBs8ntXeIn5_brHA7bpmL1efQpq2LVq6mk,3058
17
17
  learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
18
- learning_loop_node/data_classes/training.py,sha256=OlbPhm5kYHaZqgpz4_DfWnW4DQ_DK91AurReDhvWpRo,5944
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
21
  learning_loop_node/detector/detector_logic.py,sha256=agPbkFvFuQhZ04qQleWji6iYrbXsKqVGL0fseq4OXXY,1554
22
- learning_loop_node/detector/detector_node.py,sha256=AB-uXNPGxRbQwjLIN35uyk1Qww6T1bF4fqWSbwV6F1A,37395
22
+ learning_loop_node/detector/detector_node.py,sha256=4vq2gRb6lVs2lwvhWL4rC3S9D89xWGkGouy506g2h38,38314
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
@@ -52,20 +52,22 @@ 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=uCqGzni5CO8R8pHnVjsucuM4S00UxY35qxJaWIaqqjk,1186
55
+ learning_loop_node/trainer/exceptions.py,sha256=31SzhQXf3vsU-2aqoQYTjvsebcHtkJGZnKGzSvgLEvo,1899
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
- learning_loop_node/trainer/trainer_logic.py,sha256=x15bEy7uTfaGGHGYRGUl2olMeL0x0QW0vlu8nXZOaGg,9769
62
- learning_loop_node/trainer/trainer_logic_generic.py,sha256=3OcI16ijZ4q0QSKZEKah7uhoEydjxy5vKNDm-xBCuk8,31611
63
- learning_loop_node/trainer/trainer_node.py,sha256=GU4LyFA-JC9s4d-PbyKhKWyGMMIzTMqKJFNxM6tDJE4,5316
62
+ learning_loop_node/trainer/trainer_logic.py,sha256=ayBo0B9_2ZmoLW97-ahbRUpqz0jTgV7-LbWul2NC248,9726
63
+ learning_loop_node/trainer/trainer_logic_generic.py,sha256=q9QM4mGtIvp3ihMH4yuFvnO0G9t71ojeOya5OYAeKgE,35175
64
+ learning_loop_node/trainer/trainer_node.py,sha256=viYXQvUnwWfFjMKsh5Y-uWPCY2I2HwQh92w7bnBa0XY,5830
65
+ learning_loop_node/trainer/training_results.py,sha256=WOgT0SmNQ-38p0ZUnhcBWqWYbvEE9YkfGEDcRARJ3zE,8792
64
66
  learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
65
- learning_loop_node/trainer/validation_set.py,sha256=3gyqw88jadYRYDTUZVsaE0o5LGShmqaJykjr0sdNLJo,2907
67
+ learning_loop_node/trainer/validation_set.py,sha256=dHEBXqZoDVlWFyV6K5EZ4fXbXa6OuZxSw6fVw0qoJh0,3597
66
68
  learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
69
  learning_loop_node/trainer/rest/backdoor_controls.py,sha256=bCjrQvqtgd2DtyYNgftkcDe9AFHExFLtfdW_jISLwuk,5026
68
- learning_loop_node-0.27.2.dist-info/METADATA,sha256=KZ4fva5K1YHTwNrGoQKVz4ooUGgNLuISoetBOE1g2_M,20629
69
- learning_loop_node-0.27.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
70
- learning_loop_node-0.27.2.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
71
- learning_loop_node-0.27.2.dist-info/RECORD,,
70
+ learning_loop_node-0.29.0.dist-info/METADATA,sha256=0VUirHSUUAuBSs2aUlgoWgSiCIGsA7UjscKnsZViyK8,21772
71
+ learning_loop_node-0.29.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
72
+ learning_loop_node-0.29.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
73
+ learning_loop_node-0.29.0.dist-info/RECORD,,