substrax 0.1.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.
substrax/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Substrax: shared training and hardware infrastructure for the Avitai JAX stack.
2
+
3
+ The package sits below calibrax, datarax, artifex and opifex and holds the code those
4
+ packages used to carry separately: device information and placement (``devices``), device
5
+ meshes and sharding strategies (``mesh``), data-parallel training helpers (``spmd``), one
6
+ Orbax checkpoint store (``checkpoint``), training callbacks (``callbacks``) and step-wise
7
+ experiment tracking (``tracking``). Import from the subpackages; this module exposes only
8
+ the version.
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1,18 @@
1
+ """Training callbacks, best-metric tracking and early stopping."""
2
+
3
+ from substrax.callbacks.base import BaseCallback, CallbackList, TrainerLike, TrainingCallback
4
+ from substrax.callbacks.early_stopping import EarlyStoppingCallback, EarlyStoppingConfig
5
+ from substrax.callbacks.plateau import BestMetricTracker, EarlyStopping, PlateauMode
6
+
7
+
8
+ __all__ = [
9
+ "BaseCallback",
10
+ "BestMetricTracker",
11
+ "CallbackList",
12
+ "EarlyStopping",
13
+ "EarlyStoppingCallback",
14
+ "EarlyStoppingConfig",
15
+ "PlateauMode",
16
+ "TrainerLike",
17
+ "TrainingCallback",
18
+ ]
@@ -0,0 +1,158 @@
1
+ """The training-callback protocol, its no-op base and the dispatching list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from flax import nnx
9
+
10
+
11
+ @runtime_checkable
12
+ class TrainerLike(Protocol):
13
+ """What a callback may ask of the trainer that drives it."""
14
+
15
+ @property
16
+ def model(self) -> nnx.Module:
17
+ """The model being trained."""
18
+ ...
19
+
20
+
21
+ @runtime_checkable
22
+ class TrainingCallback(Protocol):
23
+ """Lifecycle hooks a training loop fires; implement any subset via ``BaseCallback``.
24
+
25
+ Parameters are positional-only so implementations may name them as they like.
26
+ """
27
+
28
+ def on_train_begin(self, trainer: TrainerLike, /) -> None:
29
+ """Called once before the first epoch."""
30
+ ...
31
+
32
+ def on_train_end(self, trainer: TrainerLike, /) -> None:
33
+ """Called once after the last epoch."""
34
+ ...
35
+
36
+ def on_epoch_begin(self, trainer: TrainerLike, epoch: int, /) -> None:
37
+ """Called before each epoch."""
38
+ ...
39
+
40
+ def on_epoch_end(self, trainer: TrainerLike, epoch: int, logs: dict[str, Any], /) -> None:
41
+ """Called after each epoch with that epoch's metrics."""
42
+ ...
43
+
44
+ def on_batch_begin(self, trainer: TrainerLike, batch: int, /) -> None:
45
+ """Called before each batch."""
46
+ ...
47
+
48
+ def on_batch_end(self, trainer: TrainerLike, batch: int, logs: dict[str, Any], /) -> None:
49
+ """Called after each batch with that batch's metrics."""
50
+ ...
51
+
52
+ def on_validation_begin(self, trainer: TrainerLike, /) -> None:
53
+ """Called before a validation pass."""
54
+ ...
55
+
56
+ def on_validation_end(self, trainer: TrainerLike, logs: dict[str, Any], /) -> None:
57
+ """Called after a validation pass with its metrics."""
58
+ ...
59
+
60
+
61
+ class BaseCallback:
62
+ """No-op implementation of every hook; subclasses override what they need."""
63
+
64
+ __slots__ = ()
65
+
66
+ def on_train_begin(self, _trainer: TrainerLike) -> None:
67
+ """Called once before the first epoch."""
68
+
69
+ def on_train_end(self, _trainer: TrainerLike) -> None:
70
+ """Called once after the last epoch."""
71
+
72
+ def on_epoch_begin(self, _trainer: TrainerLike, _epoch: int) -> None:
73
+ """Called before each epoch."""
74
+
75
+ def on_epoch_end(self, _trainer: TrainerLike, _epoch: int, _logs: dict[str, Any]) -> None:
76
+ """Called after each epoch with that epoch's metrics."""
77
+
78
+ def on_batch_begin(self, _trainer: TrainerLike, _batch: int) -> None:
79
+ """Called before each batch."""
80
+
81
+ def on_batch_end(self, _trainer: TrainerLike, _batch: int, _logs: dict[str, Any]) -> None:
82
+ """Called after each batch with that batch's metrics."""
83
+
84
+ def on_validation_begin(self, _trainer: TrainerLike) -> None:
85
+ """Called before a validation pass."""
86
+
87
+ def on_validation_end(self, _trainer: TrainerLike, _logs: dict[str, Any]) -> None:
88
+ """Called after a validation pass with its metrics."""
89
+
90
+
91
+ class CallbackList:
92
+ """Dispatches every hook to its callbacks in registration order."""
93
+
94
+ __slots__ = ("_callbacks",)
95
+
96
+ def __init__(self, callbacks: Iterable[TrainingCallback] | None = None) -> None:
97
+ """Wrap an initial set of callbacks.
98
+
99
+ Args:
100
+ callbacks: The callbacks to start with; each receives every hook, in this order.
101
+ """
102
+ self._callbacks: list[TrainingCallback] = list(callbacks) if callbacks else []
103
+
104
+ def add(self, callback: TrainingCallback) -> None:
105
+ """Append a callback."""
106
+ self._callbacks.append(callback)
107
+
108
+ def remove(self, callback: TrainingCallback) -> None:
109
+ """Remove a callback; raises ``ValueError`` if it was never added."""
110
+ self._callbacks.remove(callback)
111
+
112
+ def __len__(self) -> int:
113
+ """The number of callbacks."""
114
+ return len(self._callbacks)
115
+
116
+ def __iter__(self) -> Iterator[TrainingCallback]:
117
+ """Iterate over the callbacks in dispatch order."""
118
+ return iter(self._callbacks)
119
+
120
+ def on_train_begin(self, trainer: TrainerLike) -> None:
121
+ """Dispatch ``on_train_begin``."""
122
+ for callback in self._callbacks:
123
+ callback.on_train_begin(trainer)
124
+
125
+ def on_train_end(self, trainer: TrainerLike) -> None:
126
+ """Dispatch ``on_train_end``."""
127
+ for callback in self._callbacks:
128
+ callback.on_train_end(trainer)
129
+
130
+ def on_epoch_begin(self, trainer: TrainerLike, epoch: int) -> None:
131
+ """Dispatch ``on_epoch_begin``."""
132
+ for callback in self._callbacks:
133
+ callback.on_epoch_begin(trainer, epoch)
134
+
135
+ def on_epoch_end(self, trainer: TrainerLike, epoch: int, logs: dict[str, Any]) -> None:
136
+ """Dispatch ``on_epoch_end``."""
137
+ for callback in self._callbacks:
138
+ callback.on_epoch_end(trainer, epoch, logs)
139
+
140
+ def on_batch_begin(self, trainer: TrainerLike, batch: int) -> None:
141
+ """Dispatch ``on_batch_begin``."""
142
+ for callback in self._callbacks:
143
+ callback.on_batch_begin(trainer, batch)
144
+
145
+ def on_batch_end(self, trainer: TrainerLike, batch: int, logs: dict[str, Any]) -> None:
146
+ """Dispatch ``on_batch_end``."""
147
+ for callback in self._callbacks:
148
+ callback.on_batch_end(trainer, batch, logs)
149
+
150
+ def on_validation_begin(self, trainer: TrainerLike) -> None:
151
+ """Dispatch ``on_validation_begin``."""
152
+ for callback in self._callbacks:
153
+ callback.on_validation_begin(trainer)
154
+
155
+ def on_validation_end(self, trainer: TrainerLike, logs: dict[str, Any]) -> None:
156
+ """Dispatch ``on_validation_end``."""
157
+ for callback in self._callbacks:
158
+ callback.on_validation_end(trainer, logs)
@@ -0,0 +1,116 @@
1
+ """Early stopping callback driven by epoch logs.
2
+
3
+ Monitors a metric in the epoch logs and stops training when it stops improving, reaches a
4
+ goal, diverges, or becomes non-finite. The best-so-far and stagnation bookkeeping is
5
+ ``BestMetricTracker``; this class adds the log lookup, the thresholds and the epoch record.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from typing import Any, Literal
13
+
14
+ from substrax.callbacks.base import BaseCallback, TrainerLike
15
+ from substrax.callbacks.plateau import BestMetricTracker, PlateauMode
16
+
17
+
18
+ @dataclass(frozen=True, slots=True, kw_only=True)
19
+ class EarlyStoppingConfig:
20
+ """Configuration for early stopping.
21
+
22
+ Attributes:
23
+ monitor: Metric name to monitor (e.g., "val_loss", "accuracy").
24
+ min_delta: Minimum change to qualify as an improvement.
25
+ patience: Number of epochs with no improvement before stopping.
26
+ mode: "min" if lower is better, "max" if higher is better.
27
+ check_finite: If True, stop when metric becomes NaN or Inf.
28
+ stopping_threshold: Stop immediately when metric reaches this value.
29
+ divergence_threshold: Stop if metric exceeds this value (min mode only).
30
+ """
31
+
32
+ monitor: str = "val_loss"
33
+ min_delta: float = 0.0
34
+ patience: int = 10
35
+ mode: Literal["min", "max"] = "min"
36
+ check_finite: bool = True
37
+ stopping_threshold: float | None = None
38
+ divergence_threshold: float | None = None
39
+
40
+
41
+ class EarlyStoppingCallback(BaseCallback):
42
+ """Stop training when a monitored metric stops improving."""
43
+
44
+ __slots__ = ("_stopped_epoch", "_tracker", "config")
45
+
46
+ def __init__(self, config: EarlyStoppingConfig) -> None:
47
+ """Initialize early stopping callback.
48
+
49
+ Args:
50
+ config: Early stopping configuration.
51
+ """
52
+ self.config = config
53
+ self._tracker = BestMetricTracker(mode=config.mode, min_delta=config.min_delta)
54
+ self._stopped_epoch: int | None = None
55
+
56
+ @property
57
+ def wait_count(self) -> int:
58
+ """Epochs since the last improvement."""
59
+ return self._tracker.num_bad_epochs
60
+
61
+ @property
62
+ def best_score(self) -> float | None:
63
+ """The best monitored value so far, or ``None`` before the metric first appears."""
64
+ best = self._tracker.best
65
+ return None if math.isinf(best) else best
66
+
67
+ @property
68
+ def stopped_epoch(self) -> int | None:
69
+ """The epoch at which stopping was decided, if it has been."""
70
+ return self._stopped_epoch
71
+
72
+ @property
73
+ def should_stop(self) -> bool:
74
+ """Whether training should stop."""
75
+ return self._stopped_epoch is not None
76
+
77
+ def on_epoch_end(self, _trainer: TrainerLike, epoch: int, logs: dict[str, Any]) -> None:
78
+ """Read the monitored metric, if present, and decide whether to stop.
79
+
80
+ Args:
81
+ _trainer: The trainer instance (unused).
82
+ epoch: Current epoch number.
83
+ logs: Dictionary of metrics from this epoch.
84
+ """
85
+ value = logs.get(self.config.monitor)
86
+ if value is None:
87
+ return
88
+ current = float(value)
89
+ if self._stops_immediately(current):
90
+ self._stopped_epoch = epoch
91
+ return
92
+ self._tracker.register(current)
93
+ if self._tracker.num_bad_epochs >= self.config.patience:
94
+ self._stopped_epoch = epoch
95
+
96
+ def _stops_immediately(self, current: float) -> bool:
97
+ """Whether ``current`` ends training before patience is considered."""
98
+ if self.config.check_finite and not math.isfinite(current):
99
+ return True
100
+ return self._meets_threshold(current) or self._diverged(current)
101
+
102
+ def _meets_threshold(self, current: float) -> bool:
103
+ """Whether ``current`` reaches the stopping threshold (the training goal)."""
104
+ threshold = self.config.stopping_threshold
105
+ if threshold is None:
106
+ return False
107
+ if self._tracker.mode is PlateauMode.MIN:
108
+ return current <= threshold
109
+ return current >= threshold
110
+
111
+ def _diverged(self, current: float) -> bool:
112
+ """Whether ``current`` exceeds the divergence threshold in min mode."""
113
+ threshold = self.config.divergence_threshold
114
+ return (
115
+ threshold is not None and self._tracker.mode is PlateauMode.MIN and current > threshold
116
+ )
@@ -0,0 +1,121 @@
1
+ """Metric-driven early stopping.
2
+
3
+ Reacts to a stream of per-epoch validation metrics. Framework agnostic (pure-Python
4
+ scalar bookkeeping, no JAX state), so it composes with any training loop.
5
+
6
+ Semantics follow the established reference: :class:`EarlyStopping` mirrors the
7
+ Keras / Lightning callback (stop after ``patience`` epochs without a ``min_delta``
8
+ improvement). Learning-rate plateau decay is ``optax.contrib.reduce_on_plateau``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from enum import StrEnum
14
+
15
+
16
+ class PlateauMode(StrEnum):
17
+ """Whether a monitored metric improves by decreasing or increasing."""
18
+
19
+ MIN = "min"
20
+ MAX = "max"
21
+
22
+
23
+ class BestMetricTracker:
24
+ """Shared best-so-far + stagnation bookkeeping for the plateau callbacks.
25
+
26
+ Tracks the best monitored value and the number of consecutive updates without
27
+ a ``min_delta`` improvement, in either ``"min"`` or ``"max"`` mode.
28
+ """
29
+
30
+ def __init__(self, *, mode: PlateauMode | str, min_delta: float) -> None:
31
+ """Initialise the tracker.
32
+
33
+ Args:
34
+ mode: ``"min"`` (lower is better) or ``"max"`` (higher is better).
35
+ min_delta: Minimum absolute change counted as an improvement.
36
+
37
+ Raises:
38
+ ValueError: If ``min_delta`` is negative.
39
+ """
40
+ if min_delta < 0.0:
41
+ raise ValueError(f"min_delta must be non-negative, got {min_delta}.")
42
+ self._mode = PlateauMode(mode)
43
+ self._min_delta = float(min_delta)
44
+ self._best = float("inf") if self._mode is PlateauMode.MIN else float("-inf")
45
+ self._num_bad_epochs = 0
46
+
47
+ def _is_improvement(self, value: float) -> bool:
48
+ """Return whether ``value`` beats the best by at least ``min_delta``."""
49
+ if self._mode is PlateauMode.MIN:
50
+ return value < self._best - self._min_delta
51
+ return value > self._best + self._min_delta
52
+
53
+ def register(self, value: float) -> bool:
54
+ """Record ``value``; return ``True`` if it improves on the best so far.
55
+
56
+ Updates the best value and resets the stagnation counter on an
57
+ improvement; otherwise increments the stagnation counter.
58
+ """
59
+ if self._is_improvement(value):
60
+ self._best = float(value)
61
+ self._num_bad_epochs = 0
62
+ return True
63
+ self._num_bad_epochs += 1
64
+ return False
65
+
66
+ @property
67
+ def mode(self) -> PlateauMode:
68
+ """The improvement direction."""
69
+ return self._mode
70
+
71
+ @property
72
+ def best(self) -> float:
73
+ """The best monitored value seen so far."""
74
+ return self._best
75
+
76
+ @property
77
+ def num_bad_epochs(self) -> int:
78
+ """Consecutive updates without a ``min_delta`` improvement."""
79
+ return self._num_bad_epochs
80
+
81
+ def _reset_stagnation(self) -> None:
82
+ """Clear the stagnation counter (e.g. after an LR reduction)."""
83
+ self._num_bad_epochs = 0
84
+
85
+
86
+ class EarlyStopping(BestMetricTracker):
87
+ """Signal to stop once a monitored metric stops improving."""
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ patience: int,
93
+ min_delta: float = 0.0,
94
+ mode: PlateauMode | str = PlateauMode.MIN,
95
+ ) -> None:
96
+ """Initialise the stopper.
97
+
98
+ Args:
99
+ patience: Epochs without a ``min_delta`` improvement before stopping.
100
+ min_delta: Minimum absolute change counted as an improvement.
101
+ mode: ``"min"`` (lower is better) or ``"max"`` (higher is better).
102
+
103
+ Raises:
104
+ ValueError: If ``patience`` is not positive.
105
+ """
106
+ super().__init__(mode=mode, min_delta=min_delta)
107
+ if patience < 1:
108
+ raise ValueError(f"patience must be >= 1, got {patience}.")
109
+ self._patience = patience
110
+
111
+ def update(self, value: float) -> bool:
112
+ """Record the latest metric; return ``True`` if it improved on the best."""
113
+ return self.register(value)
114
+
115
+ @property
116
+ def should_stop(self) -> bool:
117
+ """Whether the metric has stagnated for ``patience`` epochs."""
118
+ return self._num_bad_epochs >= self._patience
119
+
120
+
121
+ __all__ = ["BestMetricTracker", "EarlyStopping", "PlateauMode"]
@@ -0,0 +1,6 @@
1
+ """The checkpoint store protocol and its Orbax implementation."""
2
+
3
+ from substrax.checkpoint.checkpoint_store import CheckpointStore, ModelLike, OrbaxCheckpointStore
4
+
5
+
6
+ __all__ = ["CheckpointStore", "ModelLike", "OrbaxCheckpointStore"]