trainloop 0.8.0__tar.gz → 0.10.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: trainloop
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Minimal PyTorch training loop with hooks and checkpointing.
5
5
  Author: Karim Knaebel
6
6
  Author-email: Karim Knaebel <contact@knaebel.dev>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trainloop"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "Minimal PyTorch training loop with hooks and checkpointing."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -21,6 +21,7 @@ dev = [
21
21
  "zensical>=0.0.10",
22
22
  "mkdocstrings-python>=2.0.1",
23
23
  "pillow>=11.3.0",
24
+ "tensorboard>=2.20.0",
24
25
  "wandb>=0.20.1",
25
26
  "numpy>=2.2.6",
26
27
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trainloop"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "Minimal PyTorch training loop with hooks and checkpointing."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -22,6 +22,7 @@ dev = [
22
22
  "zensical>=0.0.10",
23
23
  "mkdocstrings-python>=2.0.1",
24
24
  "pillow>=11.3.0",
25
+ "tensorboard>=2.20.0",
25
26
  "wandb>=0.20.1",
26
27
  "numpy>=2.2.6",
27
28
  ]
@@ -6,6 +6,7 @@ from .hooks import (
6
6
  ImageFileLoggerHook,
7
7
  ProgressHook,
8
8
  StatsHook,
9
+ TensorBoardHook,
9
10
  TrainingStats,
10
11
  WandbHook,
11
12
  )
@@ -21,6 +22,7 @@ __all__ = [
21
22
  "LossNoneWarning",
22
23
  "ProgressHook",
23
24
  "StatsHook",
25
+ "TensorBoardHook",
24
26
  "TrainingStats",
25
27
  "WandbHook",
26
28
  "map_nested_tensor",
@@ -26,11 +26,18 @@ except ImportError:
26
26
  # only needed for WandbHook
27
27
  pass
28
28
 
29
+ try:
30
+ import numpy as np
31
+ from torch.utils.tensorboard import SummaryWriter
32
+ except ImportError:
33
+ # only needed for TensorBoardHook
34
+ pass
35
+
29
36
  try:
30
37
  from PIL import Image
31
38
  from PIL.Image import Image as PILImage
32
39
  except ImportError:
33
- # only needed for WandbHook JPEG conversion
40
+ # only needed for WandbHook and TensorBoardHook image conversion
34
41
  pass
35
42
 
36
43
  from .trainer import BaseTrainer, Records
@@ -76,7 +83,9 @@ class BaseHook:
76
83
  def on_after_train(self, trainer: BaseTrainer):
77
84
  pass
78
85
 
79
- def on_log(self, trainer: BaseTrainer, records: dict, dry_run: bool = False):
86
+ def on_log_scalars(
87
+ self, trainer: BaseTrainer, records: dict, dry_run: bool = False
88
+ ):
80
89
  pass
81
90
 
82
91
  def on_log_images(self, trainer: BaseTrainer, records: dict, dry_run: bool = False):
@@ -620,6 +629,9 @@ class EMAHook(BaseHook):
620
629
  class WandbHook(BaseHook):
621
630
  """Log metrics and images to Weights & Biases (rank 0 only).
622
631
 
632
+ Nested scalar key components are joined with ``/``. Avoid ``/`` within an
633
+ individual component because it is also W&B's panel namespace separator.
634
+
623
635
  Args:
624
636
  project: W&B project name.
625
637
  config: Optional config dict or JSON file path to log.
@@ -671,7 +683,9 @@ class WandbHook(BaseHook):
671
683
  if _dist_rank() == 0:
672
684
  self.wandb.finish()
673
685
 
674
- def on_log(self, trainer: BaseTrainer, records: dict, dry_run: bool = False):
686
+ def on_log_scalars(
687
+ self, trainer: BaseTrainer, records: dict, dry_run: bool = False
688
+ ):
675
689
  if _dist_rank() == 0:
676
690
  data = {"/".join(k): v for k, v in flatten_nested_dict(records).items()}
677
691
  if not dry_run:
@@ -738,6 +752,109 @@ class WandbHook(BaseHook):
738
752
  return None
739
753
 
740
754
 
755
+ class TensorBoardHook(BaseHook):
756
+ """Log metrics and images to TensorBoard (rank 0 only).
757
+
758
+ Nested namespace components are joined with ``namespace_separator`` and
759
+ the final name is separated with ``/``. Avoid these separators within
760
+ individual keys because TensorBoard uses them to organize tags.
761
+
762
+ Args:
763
+ texts: Optional text values to log when training starts.
764
+ namespace_separator: Separator for nested tag prefixes; the final TensorBoard
765
+ name boundary is always "/".
766
+ """
767
+
768
+ def __init__(
769
+ self,
770
+ texts: dict[str, Any] | None = None,
771
+ namespace_separator: str = "/",
772
+ ):
773
+ self.writer = None
774
+ self.purge_step = None
775
+ self.texts = texts
776
+ self.namespace_separator = namespace_separator
777
+
778
+ def on_before_train(self, trainer: BaseTrainer):
779
+ if _dist_rank() == 0:
780
+ self._open_writer(trainer)
781
+ for k, v in (self.texts or {}).items():
782
+ self.writer.add_text(k, str(v))
783
+ self.writer.flush()
784
+
785
+ def on_load_state_dict(self, trainer: BaseTrainer, state_dict: dict):
786
+ self.purge_step = trainer.step + 1 # the step that will be logged next
787
+ if self.writer is not None:
788
+ self.writer.close()
789
+ self._open_writer(trainer)
790
+
791
+ def on_after_train(self, trainer: BaseTrainer):
792
+ if self.writer is None:
793
+ return
794
+
795
+ self.writer.close()
796
+ self.writer = None
797
+
798
+ def on_log_scalars(
799
+ self, trainer: BaseTrainer, records: dict, dry_run: bool = False
800
+ ):
801
+ if self.writer is None:
802
+ return
803
+
804
+ data = {self._format_tag(k): v for k, v in flatten_nested_dict(records).items()}
805
+ if not dry_run:
806
+ for k, v in data.items():
807
+ self.writer.add_scalar(k, v, global_step=trainer.step)
808
+ self.writer.flush()
809
+ else:
810
+ trainer.logger.debug(f"Dry run log. Would log: {data}")
811
+
812
+ def on_log_images(self, trainer: BaseTrainer, records: dict, dry_run: bool = False):
813
+ if self.writer is None:
814
+ return
815
+
816
+ data = {
817
+ self._format_tag((*k, "image")): np.asarray(self._pil_to_rgb(v))
818
+ for k, v in flatten_nested_dict(records).items()
819
+ }
820
+ if not dry_run:
821
+ for k, v in data.items():
822
+ self.writer.add_image(k, v, global_step=trainer.step, dataformats="HWC")
823
+ self.writer.flush()
824
+ else:
825
+ trainer.logger.debug(f"Dry run log. Would log: {data}")
826
+
827
+ def _open_writer(self, trainer: BaseTrainer):
828
+ self.writer = SummaryWriter(
829
+ log_dir=str(trainer.workspace),
830
+ purge_step=self.purge_step,
831
+ max_queue=1_000, # we already flush after every step
832
+ )
833
+ self.purge_step = None
834
+
835
+ def _format_tag(self, key: tuple[str, ...]):
836
+ *namespace, name = key
837
+ return (
838
+ f"{self.namespace_separator.join(namespace)}/{name}" if namespace else name
839
+ )
840
+
841
+ @staticmethod
842
+ def _pil_to_rgb(img: "PILImage", bg_color: tuple = (255, 255, 255)):
843
+ if img.mode == "RGB":
844
+ return img
845
+ elif img.mode == "L":
846
+ return img.convert("RGB")
847
+ elif img.mode in ("RGBA", "LA"):
848
+ background = Image.new("RGB", img.size, bg_color)
849
+ background.paste(img, mask=img.getchannel("A"))
850
+ return background
851
+ else:
852
+ warnings.warn(
853
+ f"Trying to convert {img.mode} to RGB in a best-effort manner."
854
+ )
855
+ return img.convert("RGB")
856
+
857
+
741
858
  class ImageFileLoggerHook(BaseHook):
742
859
  """Persist logged images to ``workspace/visualizations`` on rank 0.
743
860
 
@@ -349,17 +349,22 @@ class BaseTrainer:
349
349
  def unwrapped_model(self):
350
350
  return self.unwrap(self.model)
351
351
 
352
- def log(self, records: dict[str, Any], dry_run: bool = False):
352
+ def log_scalars(self, records: dict[str, Any], dry_run: bool = False):
353
353
  """
354
- Dispatch numeric records to hooks (e.g., trackers or stdout).
354
+ Dispatch nested scalar records to logging hooks.
355
+
356
+ Dictionary keys are treated as path components. Logging hooks may join
357
+ nested components with backend-specific namespace separators, such as
358
+ ``/``. Avoid those separators within individual keys because distinct
359
+ nested paths may otherwise map to the same backend metric name.
355
360
 
356
361
  Args:
357
- records: Nested dict of numeric metrics to log.
362
+ records: Nested dict of scalar metrics to log.
358
363
  dry_run: If True, hooks should avoid side effects and only report intent.
359
364
  """
360
- self.logger.debug("log()")
365
+ self.logger.debug("log_scalars()")
361
366
  for h in self.hooks:
362
- h.on_log(self, records, dry_run=dry_run)
367
+ h.on_log_scalars(self, records, dry_run=dry_run)
363
368
 
364
369
  def log_images(self, records: dict[str, Any], dry_run: bool = False):
365
370
  """
File without changes