stagereg 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.
- stagereg/__init__.py +1 -0
- stagereg/core/__init__.py +149 -0
- stagereg/core/checkpoints.py +100 -0
- stagereg/core/distributions.py +60 -0
- stagereg/core/early_stopping.py +252 -0
- stagereg/core/losses.py +130 -0
- stagereg/core/metric_registry.py +545 -0
- stagereg/core/metrics.py +168 -0
- stagereg/core/naming.py +138 -0
- stagereg/core/protocols.py +52 -0
- stagereg/core/stages.py +46 -0
- stagereg/core/tracking.py +143 -0
- stagereg/core/types.py +228 -0
- stagereg/jax/__init__.py +103 -0
- stagereg/jax/bayes_by_backprop.py +367 -0
- stagereg/jax/distributions.py +496 -0
- stagereg/jax/epoch_loop.py +516 -0
- stagereg/jax/evaluation.py +198 -0
- stagereg/jax/losses.py +106 -0
- stagereg/jax/metrics.py +758 -0
- stagereg/jax/posterior.py +249 -0
- stagereg/jax/posterior_stage.py +688 -0
- stagereg/jax/psgld.py +337 -0
- stagereg/jax/rng.py +20 -0
- stagereg/jax/stage_checkpoints.py +215 -0
- stagereg/jax/state.py +283 -0
- stagereg/jax/supervised.py +209 -0
- stagereg/jax/supervised_staged.py +731 -0
- stagereg/jax/validation.py +61 -0
- stagereg/py.typed +0 -0
- stagereg-0.1.0.dist-info/METADATA +115 -0
- stagereg-0.1.0.dist-info/RECORD +34 -0
- stagereg-0.1.0.dist-info/WHEEL +4 -0
- stagereg-0.1.0.dist-info/licenses/LICENSE +28 -0
stagereg/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Probabilistic regression with explicit, composable training stages."""
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Backend-neutral contracts and utilities for probabilistic regression."""
|
|
2
|
+
|
|
3
|
+
from stagereg.core.checkpoints import (
|
|
4
|
+
Checkpoint,
|
|
5
|
+
CheckpointStore,
|
|
6
|
+
InMemoryCheckpointStore,
|
|
7
|
+
)
|
|
8
|
+
from stagereg.core.distributions import (
|
|
9
|
+
DistributionHead,
|
|
10
|
+
DistributionLoss,
|
|
11
|
+
Likelihood,
|
|
12
|
+
Loss,
|
|
13
|
+
PredictionLoss,
|
|
14
|
+
PredictiveDistribution,
|
|
15
|
+
)
|
|
16
|
+
from stagereg.core.early_stopping import (
|
|
17
|
+
EarlyStopper,
|
|
18
|
+
EarlyStoppingDecision,
|
|
19
|
+
EarlyStoppingState,
|
|
20
|
+
OptimizationMode,
|
|
21
|
+
)
|
|
22
|
+
from stagereg.core.losses import (
|
|
23
|
+
BetaNLLLoss,
|
|
24
|
+
GaussianNLLLoss,
|
|
25
|
+
NegativeLogLikelihoodLoss,
|
|
26
|
+
SquaredErrorLoss,
|
|
27
|
+
add_epsilon,
|
|
28
|
+
)
|
|
29
|
+
from stagereg.core.metric_registry import (
|
|
30
|
+
ContinuousRankedProbabilityScore,
|
|
31
|
+
EpochMetric,
|
|
32
|
+
EpochPredictionData,
|
|
33
|
+
EvaluationGrid,
|
|
34
|
+
IntervalCoverage,
|
|
35
|
+
MetricRequirements,
|
|
36
|
+
NegativeLogLikelihood,
|
|
37
|
+
PointContinuousRankedProbabilityScore,
|
|
38
|
+
PredictionInterval,
|
|
39
|
+
RootMeanSquaredError,
|
|
40
|
+
SampleContinuousRankedProbabilityScore,
|
|
41
|
+
WeightedSpread,
|
|
42
|
+
)
|
|
43
|
+
from stagereg.core.metrics import (
|
|
44
|
+
cdf,
|
|
45
|
+
coverage,
|
|
46
|
+
crps,
|
|
47
|
+
point_crps,
|
|
48
|
+
rmse,
|
|
49
|
+
sample_crps,
|
|
50
|
+
wsu,
|
|
51
|
+
)
|
|
52
|
+
from stagereg.core.naming import (
|
|
53
|
+
MetricTag,
|
|
54
|
+
Split,
|
|
55
|
+
flatten_parameters,
|
|
56
|
+
metric_tag,
|
|
57
|
+
parse_metric_tag,
|
|
58
|
+
)
|
|
59
|
+
from stagereg.core.protocols import (
|
|
60
|
+
Dataset,
|
|
61
|
+
LoaderFactory,
|
|
62
|
+
Optimizer,
|
|
63
|
+
Step,
|
|
64
|
+
ValidationStrategy,
|
|
65
|
+
)
|
|
66
|
+
from stagereg.core.stages import StageState, TrainingStage, validate_transition
|
|
67
|
+
from stagereg.core.tracking import (
|
|
68
|
+
Decision,
|
|
69
|
+
EventSink,
|
|
70
|
+
ExperimentTracker,
|
|
71
|
+
TrackerEventSink,
|
|
72
|
+
TrainingEvent,
|
|
73
|
+
)
|
|
74
|
+
from stagereg.core.types import (
|
|
75
|
+
Array,
|
|
76
|
+
Batch,
|
|
77
|
+
CheckpointRef,
|
|
78
|
+
ParameterRole,
|
|
79
|
+
PyTree,
|
|
80
|
+
StageResult,
|
|
81
|
+
TrainingState,
|
|
82
|
+
ValidationResult,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"Array",
|
|
87
|
+
"Batch",
|
|
88
|
+
"BetaNLLLoss",
|
|
89
|
+
"Checkpoint",
|
|
90
|
+
"CheckpointRef",
|
|
91
|
+
"CheckpointStore",
|
|
92
|
+
"ContinuousRankedProbabilityScore",
|
|
93
|
+
"Dataset",
|
|
94
|
+
"Decision",
|
|
95
|
+
"DistributionHead",
|
|
96
|
+
"DistributionLoss",
|
|
97
|
+
"EarlyStopper",
|
|
98
|
+
"EarlyStoppingDecision",
|
|
99
|
+
"EarlyStoppingState",
|
|
100
|
+
"EpochMetric",
|
|
101
|
+
"EpochPredictionData",
|
|
102
|
+
"EvaluationGrid",
|
|
103
|
+
"EventSink",
|
|
104
|
+
"ExperimentTracker",
|
|
105
|
+
"GaussianNLLLoss",
|
|
106
|
+
"InMemoryCheckpointStore",
|
|
107
|
+
"IntervalCoverage",
|
|
108
|
+
"Likelihood",
|
|
109
|
+
"LoaderFactory",
|
|
110
|
+
"Loss",
|
|
111
|
+
"MetricRequirements",
|
|
112
|
+
"MetricTag",
|
|
113
|
+
"NegativeLogLikelihood",
|
|
114
|
+
"NegativeLogLikelihoodLoss",
|
|
115
|
+
"OptimizationMode",
|
|
116
|
+
"Optimizer",
|
|
117
|
+
"ParameterRole",
|
|
118
|
+
"PointContinuousRankedProbabilityScore",
|
|
119
|
+
"PredictionInterval",
|
|
120
|
+
"PredictionLoss",
|
|
121
|
+
"PredictiveDistribution",
|
|
122
|
+
"PyTree",
|
|
123
|
+
"RootMeanSquaredError",
|
|
124
|
+
"SampleContinuousRankedProbabilityScore",
|
|
125
|
+
"Split",
|
|
126
|
+
"SquaredErrorLoss",
|
|
127
|
+
"StageResult",
|
|
128
|
+
"StageState",
|
|
129
|
+
"Step",
|
|
130
|
+
"TrackerEventSink",
|
|
131
|
+
"TrainingEvent",
|
|
132
|
+
"TrainingStage",
|
|
133
|
+
"TrainingState",
|
|
134
|
+
"ValidationResult",
|
|
135
|
+
"ValidationStrategy",
|
|
136
|
+
"WeightedSpread",
|
|
137
|
+
"add_epsilon",
|
|
138
|
+
"cdf",
|
|
139
|
+
"coverage",
|
|
140
|
+
"crps",
|
|
141
|
+
"flatten_parameters",
|
|
142
|
+
"metric_tag",
|
|
143
|
+
"parse_metric_tag",
|
|
144
|
+
"point_crps",
|
|
145
|
+
"rmse",
|
|
146
|
+
"sample_crps",
|
|
147
|
+
"validate_transition",
|
|
148
|
+
"wsu",
|
|
149
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Checkpoint value objects and persistence protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from stagereg.core.types import TrainingState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Checkpoint:
|
|
14
|
+
"""The complete state necessary to resume staged training.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
state: Snapshot of the [`TrainingState`][stagereg.core.TrainingState] at
|
|
18
|
+
the time the checkpoint was saved.
|
|
19
|
+
epoch: Epoch of the active stage at which the checkpoint was saved.
|
|
20
|
+
parameters: Snapshot of the model's parameters, in the backend's own
|
|
21
|
+
format. Defaults to ``None``.
|
|
22
|
+
optimizer_state: Snapshot of the optimizer's state, in the backend's own
|
|
23
|
+
format. Defaults to ``None``.
|
|
24
|
+
rng_state: Random key to resume training with. It mirrors the
|
|
25
|
+
``rng_state`` of the embedded ``state``. Defaults to ``None``.
|
|
26
|
+
early_stopping_state: State of the early stopper, such as an
|
|
27
|
+
[`EarlyStoppingState`][stagereg.core.EarlyStoppingState], so that
|
|
28
|
+
patience and the best value carry over on resume. Defaults to
|
|
29
|
+
``None``.
|
|
30
|
+
metadata: Information about the checkpoint, such as the stage that saved
|
|
31
|
+
it. Defaults to an empty mapping.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
state: TrainingState
|
|
35
|
+
epoch: int
|
|
36
|
+
parameters: Any | None = None
|
|
37
|
+
optimizer_state: Any | None = None
|
|
38
|
+
rng_state: Any | None = None
|
|
39
|
+
early_stopping_state: Any | None = None
|
|
40
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CheckpointStore(Protocol):
|
|
44
|
+
"""Persists checkpoints under opaque keys."""
|
|
45
|
+
|
|
46
|
+
def save(self, key: str, checkpoint: Checkpoint) -> None: ...
|
|
47
|
+
|
|
48
|
+
def load(self, key: str) -> Checkpoint: ...
|
|
49
|
+
|
|
50
|
+
def exists(self, key: str) -> bool: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InMemoryCheckpointStore:
|
|
54
|
+
"""A [`CheckpointStore`][stagereg.core.CheckpointStore] kept in process memory.
|
|
55
|
+
|
|
56
|
+
Checkpoints live only for the lifetime of the store, which makes it the natural
|
|
57
|
+
choice for examples, tests, and short runs that only need the best checkpoint of the
|
|
58
|
+
current process restored at the end. Saving under an existing key replaces the
|
|
59
|
+
checkpoint held there.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._checkpoints: dict[str, Checkpoint] = {}
|
|
64
|
+
|
|
65
|
+
def save(self, key: str, checkpoint: Checkpoint) -> None:
|
|
66
|
+
"""Store a checkpoint under a key, replacing any checkpoint there.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
key: The opaque key to store the checkpoint under.
|
|
70
|
+
checkpoint: The checkpoint to store.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
None.
|
|
74
|
+
"""
|
|
75
|
+
self._checkpoints[key] = checkpoint
|
|
76
|
+
|
|
77
|
+
def load(self, key: str) -> Checkpoint:
|
|
78
|
+
"""Return the checkpoint stored under a key.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key: The key the checkpoint was saved under.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The stored checkpoint.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
KeyError: If no checkpoint has been saved under ``key``.
|
|
88
|
+
"""
|
|
89
|
+
return self._checkpoints[key]
|
|
90
|
+
|
|
91
|
+
def exists(self, key: str) -> bool:
|
|
92
|
+
"""Report whether a checkpoint is stored under a key.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
key: The key to look up.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``True`` if a checkpoint is stored under ``key``.
|
|
99
|
+
"""
|
|
100
|
+
return key in self._checkpoints
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Protocols for distribution-valued model predictions and objectives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from stagereg.core.types import Array, Batch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PredictiveDistribution(Protocol):
|
|
11
|
+
"""A probability distribution produced by a regression model.
|
|
12
|
+
|
|
13
|
+
This protocol is the backend-neutral model/loss boundary. Epoch metrics use backend
|
|
14
|
+
adapters to materialize host arrays rather than retaining distribution objects,
|
|
15
|
+
which intentionally have no concatenation or indexing contract.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def batch_shape(self) -> tuple[int, ...]: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def event_shape(self) -> tuple[int, ...]: ...
|
|
23
|
+
|
|
24
|
+
def log_prob(self, targets: Array) -> Array: ...
|
|
25
|
+
|
|
26
|
+
def sample(self, key: Array, sample_shape: tuple[int, ...] = ()) -> Array: ...
|
|
27
|
+
|
|
28
|
+
def mean(self) -> Array: ...
|
|
29
|
+
|
|
30
|
+
def variance(self) -> Array: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DistributionHead(Protocol):
|
|
34
|
+
"""Produces a predictive distribution from model features."""
|
|
35
|
+
|
|
36
|
+
def __call__(self, features: Array) -> PredictiveDistribution: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Likelihood(Protocol):
|
|
40
|
+
"""Evaluates a target under a predictive distribution."""
|
|
41
|
+
|
|
42
|
+
def log_prob(self, prediction: PredictiveDistribution, targets: Array) -> Array: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DistributionLoss(Protocol):
|
|
46
|
+
"""Computes an unreduced loss from a predictive distribution."""
|
|
47
|
+
|
|
48
|
+
def per_example(
|
|
49
|
+
self, prediction: PredictiveDistribution, batch: Batch
|
|
50
|
+
) -> Array: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PredictionLoss(Protocol):
|
|
54
|
+
"""Computes an unreduced loss from deterministic array predictions."""
|
|
55
|
+
|
|
56
|
+
def per_example(self, prediction: Array, batch: Batch) -> Array: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Backward-compatible alias for the original distribution-loss protocol.
|
|
60
|
+
Loss = DistributionLoss
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Backend-neutral early-stopping policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from math import isfinite
|
|
8
|
+
|
|
9
|
+
from stagereg.core.naming import Split
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OptimizationMode(StrEnum):
|
|
13
|
+
"""The direction in which a monitored metric is considered improved."""
|
|
14
|
+
|
|
15
|
+
MIN = "min"
|
|
16
|
+
MAX = "max"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EarlyStoppingState:
|
|
21
|
+
"""Checkpointable state for a configured early-stopping policy.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
metric: Name of the monitored metric.
|
|
25
|
+
mode: Direction in which the metric is considered improved.
|
|
26
|
+
min_delta: Minimum change required to qualify as an improvement.
|
|
27
|
+
patience: Number of consecutive non-improving epochs tolerated
|
|
28
|
+
before stopping.
|
|
29
|
+
source: Split the monitored metric is measured on.
|
|
30
|
+
best_score: Best value observed so far, or ``None`` if no value
|
|
31
|
+
has been observed yet.
|
|
32
|
+
best_epoch: Epoch at which ``best_score`` was observed, or
|
|
33
|
+
``None`` if no value has been observed yet.
|
|
34
|
+
non_improving_epochs: Number of consecutive epochs without an
|
|
35
|
+
improvement.
|
|
36
|
+
stopped: Whether patience has been exhausted and training should
|
|
37
|
+
stop.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
metric: str
|
|
41
|
+
mode: OptimizationMode
|
|
42
|
+
min_delta: float
|
|
43
|
+
patience: int
|
|
44
|
+
source: Split = Split.VALIDATION
|
|
45
|
+
best_score: float | None = None
|
|
46
|
+
best_epoch: int | None = None
|
|
47
|
+
non_improving_epochs: int = 0
|
|
48
|
+
stopped: bool = False
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
"""Validate checkpointable early-stopping state.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If configuration or observation fields cannot represent
|
|
55
|
+
a state produced by [`EarlyStopper`][stagereg.core.EarlyStopper].
|
|
56
|
+
"""
|
|
57
|
+
if not self.metric:
|
|
58
|
+
raise ValueError("metric must be a non-empty string.")
|
|
59
|
+
if self.patience < 0:
|
|
60
|
+
raise ValueError("patience must be non-negative.")
|
|
61
|
+
if not isfinite(self.min_delta) or self.min_delta < 0:
|
|
62
|
+
raise ValueError("min_delta must be finite and non-negative.")
|
|
63
|
+
if self.best_score is not None and not isfinite(self.best_score):
|
|
64
|
+
raise ValueError("best_score must be finite when provided.")
|
|
65
|
+
if self.best_epoch is not None and self.best_epoch < 0:
|
|
66
|
+
raise ValueError("best_epoch must be non-negative when provided.")
|
|
67
|
+
if (self.best_score is None) != (self.best_epoch is None):
|
|
68
|
+
raise ValueError("best_score and best_epoch must be provided together.")
|
|
69
|
+
if self.non_improving_epochs < 0:
|
|
70
|
+
raise ValueError("non_improving_epochs must be non-negative.")
|
|
71
|
+
if self.best_score is None and self.non_improving_epochs != 0:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"non_improving_epochs must be zero before the first observation."
|
|
74
|
+
)
|
|
75
|
+
expected_stopped = self.non_improving_epochs > self.patience
|
|
76
|
+
if self.stopped is not expected_stopped:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"stopped must indicate whether non-improving epochs exceed patience."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class EarlyStoppingDecision:
|
|
84
|
+
"""The result of observing one monitored metric value.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
state: Updated early-stopping state after the observation.
|
|
88
|
+
improved: Whether the observed value improved on the best score.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
state: EarlyStoppingState
|
|
92
|
+
improved: bool
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def should_stop(self) -> bool:
|
|
96
|
+
"""Whether the configured patience has been exhausted."""
|
|
97
|
+
return self.state.stopped
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EarlyStopper:
|
|
101
|
+
"""Tracks a scalar metric and decides when training should stop."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
metric: str,
|
|
107
|
+
mode: OptimizationMode | str,
|
|
108
|
+
patience: int,
|
|
109
|
+
min_delta: float = 0.0,
|
|
110
|
+
source: Split | str = Split.VALIDATION,
|
|
111
|
+
state: EarlyStoppingState | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Initialize an early stopper.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
metric: Name of the metric to monitor.
|
|
117
|
+
mode: Direction in which the metric is considered improved,
|
|
118
|
+
either ``"min"`` or ``"max"``.
|
|
119
|
+
patience: Number of consecutive non-improving epochs
|
|
120
|
+
tolerated before stopping.
|
|
121
|
+
min_delta: Minimum change required to qualify as an
|
|
122
|
+
improvement. Defaults to ``0.0``.
|
|
123
|
+
source: Split the monitored metric is measured on, either
|
|
124
|
+
``"train"`` or ``"validation"``. Defaults to
|
|
125
|
+
``Split.VALIDATION``.
|
|
126
|
+
state: Previously checkpointed state to resume from. Must
|
|
127
|
+
match the configuration passed to this constructor.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: If ``metric`` is empty, ``patience`` is
|
|
131
|
+
negative, ``min_delta`` is not a finite non-negative
|
|
132
|
+
number, ``mode`` or ``source`` is not a recognised value,
|
|
133
|
+
or ``state`` does not match the given configuration.
|
|
134
|
+
"""
|
|
135
|
+
if not metric:
|
|
136
|
+
raise ValueError("metric must be a non-empty string.")
|
|
137
|
+
if patience < 0:
|
|
138
|
+
raise ValueError("patience must be non-negative.")
|
|
139
|
+
if not isfinite(min_delta) or min_delta < 0:
|
|
140
|
+
raise ValueError("min_delta must be finite and non-negative.")
|
|
141
|
+
|
|
142
|
+
normalized_mode = OptimizationMode(mode)
|
|
143
|
+
normalized_source = Split(source)
|
|
144
|
+
self._state = state or EarlyStoppingState(
|
|
145
|
+
metric=metric,
|
|
146
|
+
mode=normalized_mode,
|
|
147
|
+
min_delta=min_delta,
|
|
148
|
+
patience=patience,
|
|
149
|
+
source=normalized_source,
|
|
150
|
+
)
|
|
151
|
+
if (
|
|
152
|
+
self._state.metric != metric
|
|
153
|
+
or self._state.mode != normalized_mode
|
|
154
|
+
or self._state.min_delta != min_delta
|
|
155
|
+
or self._state.patience != patience
|
|
156
|
+
or self._state.source != normalized_source
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("state does not match the early-stopper configuration.")
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def state(self) -> EarlyStoppingState:
|
|
162
|
+
"""Return the current checkpointable stopping state."""
|
|
163
|
+
return self._state
|
|
164
|
+
|
|
165
|
+
def expects_validation(self) -> bool:
|
|
166
|
+
"""Whether the monitored metric must come from validation.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
``True`` if the metric configured for this stopper is
|
|
170
|
+
measured on the validation split (i.e. requires a
|
|
171
|
+
[`ValidationStrategy`][stagereg.core.ValidationStrategy] to be
|
|
172
|
+
supplied to the training loop), ``False`` if it is measured
|
|
173
|
+
on the training split.
|
|
174
|
+
"""
|
|
175
|
+
return self._state.source is Split.VALIDATION
|
|
176
|
+
|
|
177
|
+
def monitored_metric_name(self) -> str:
|
|
178
|
+
"""Return the name of the metric this stopper monitors.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The metric name passed to the constructor, used by callers
|
|
182
|
+
to look up the corresponding value in per-epoch metrics.
|
|
183
|
+
"""
|
|
184
|
+
return self._state.metric
|
|
185
|
+
|
|
186
|
+
def observe(self, value: float, *, epoch: int) -> EarlyStoppingDecision:
|
|
187
|
+
"""Record a metric observed after ``epoch`` and return its decision.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
value: The monitored metric value observed for this epoch.
|
|
191
|
+
epoch: The epoch at which ``value`` was observed.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
An [`EarlyStoppingDecision`][stagereg.core.EarlyStoppingDecision]
|
|
195
|
+
with the updated state and whether ``value`` improved on the best
|
|
196
|
+
score so far.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If ``epoch`` is negative or ``value`` is not
|
|
200
|
+
finite.
|
|
201
|
+
RuntimeError: If early stopping has already been triggered.
|
|
202
|
+
"""
|
|
203
|
+
if epoch < 0:
|
|
204
|
+
raise ValueError("epoch must be non-negative.")
|
|
205
|
+
if not isfinite(value):
|
|
206
|
+
raise ValueError("observed metric value must be finite.")
|
|
207
|
+
if self._state.stopped:
|
|
208
|
+
raise RuntimeError("cannot observe a metric after early stopping.")
|
|
209
|
+
|
|
210
|
+
improved = self._is_improvement(value)
|
|
211
|
+
if improved:
|
|
212
|
+
self._state = EarlyStoppingState(
|
|
213
|
+
metric=self._state.metric,
|
|
214
|
+
mode=self._state.mode,
|
|
215
|
+
min_delta=self._state.min_delta,
|
|
216
|
+
patience=self._state.patience,
|
|
217
|
+
source=self._state.source,
|
|
218
|
+
best_score=value,
|
|
219
|
+
best_epoch=epoch,
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
non_improving_epochs = self._state.non_improving_epochs + 1
|
|
223
|
+
self._state = EarlyStoppingState(
|
|
224
|
+
metric=self._state.metric,
|
|
225
|
+
mode=self._state.mode,
|
|
226
|
+
min_delta=self._state.min_delta,
|
|
227
|
+
patience=self._state.patience,
|
|
228
|
+
source=self._state.source,
|
|
229
|
+
best_score=self._state.best_score,
|
|
230
|
+
best_epoch=self._state.best_epoch,
|
|
231
|
+
non_improving_epochs=non_improving_epochs,
|
|
232
|
+
stopped=non_improving_epochs > self._state.patience,
|
|
233
|
+
)
|
|
234
|
+
return EarlyStoppingDecision(state=self._state, improved=improved)
|
|
235
|
+
|
|
236
|
+
def _is_improvement(self, value: float) -> bool:
|
|
237
|
+
"""Determine whether ``value`` improves on the current best score.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
value: The candidate metric value.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
``True`` if ``value`` is an improvement over the current best
|
|
244
|
+
score given the configured mode and ``min_delta``.
|
|
245
|
+
"""
|
|
246
|
+
if self._state.best_score is None:
|
|
247
|
+
return True
|
|
248
|
+
match self._state.mode:
|
|
249
|
+
case OptimizationMode.MIN:
|
|
250
|
+
return value < self._state.best_score - self._state.min_delta
|
|
251
|
+
case OptimizationMode.MAX:
|
|
252
|
+
return value > self._state.best_score + self._state.min_delta
|
stagereg/core/losses.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Backend-neutral per-example objectives for probabilistic regression."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from stagereg.core.distributions import PredictiveDistribution
|
|
10
|
+
from stagereg.core.types import Array, Batch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _identity(value: Array) -> Array:
|
|
14
|
+
"""Return ``value`` unchanged; the default no-op gradient-stopping hook."""
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class NegativeLogLikelihoodLoss:
|
|
20
|
+
"""Configurable negative log-likelihood for predictive distributions.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
beta: Variance-reweighting exponent in ``[0, 1]``. Zero recovers
|
|
24
|
+
ordinary negative log-likelihood.
|
|
25
|
+
stop_gradient: Callable excluding the variance weight from gradient
|
|
26
|
+
propagation. Defaults to the identity, which lets gradients flow
|
|
27
|
+
through the weight. With JAX and ``beta > 0``, pass
|
|
28
|
+
``jax.lax.stop_gradient``; otherwise a different objective from
|
|
29
|
+
beta-NLL is trained.
|
|
30
|
+
target_transform: Callable applied to targets before evaluating the
|
|
31
|
+
predictive distribution. Defaults to the identity transform.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
beta: float = 0.0
|
|
35
|
+
stop_gradient: Callable[[Array], Array] = field(default=_identity)
|
|
36
|
+
target_transform: Callable[[Array], Array] = field(default=_identity)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
"""Validate the variance-reweighting exponent.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If ``beta`` is outside ``[0, 1]``.
|
|
43
|
+
"""
|
|
44
|
+
if not 0.0 <= self.beta <= 1.0:
|
|
45
|
+
raise ValueError("beta must be within [0, 1].")
|
|
46
|
+
|
|
47
|
+
def per_example(self, prediction: PredictiveDistribution, batch: Batch) -> Array:
|
|
48
|
+
"""Compute the optionally transformed and beta-weighted NLL.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
prediction: The predictive distribution produced for ``batch``.
|
|
52
|
+
batch: Batch whose targets are scored under ``prediction``.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An array of per-example negative log-likelihood values.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If ``batch.targets`` is missing.
|
|
59
|
+
"""
|
|
60
|
+
if batch.targets is None:
|
|
61
|
+
raise ValueError("batch.targets must be provided.")
|
|
62
|
+
targets = self.target_transform(batch.targets)
|
|
63
|
+
nll = -prediction.log_prob(targets)
|
|
64
|
+
if self.beta == 0.0:
|
|
65
|
+
return nll
|
|
66
|
+
weight = self.stop_gradient(prediction.variance()) ** self.beta
|
|
67
|
+
return nll * weight
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class SquaredErrorLoss:
|
|
72
|
+
"""Per-example squared error for deterministic predictions."""
|
|
73
|
+
|
|
74
|
+
def per_example(self, prediction: Array, batch: Batch) -> Array:
|
|
75
|
+
"""Compute squared prediction errors.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
prediction: Deterministic model predictions.
|
|
79
|
+
batch: Batch containing regression targets.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Elementwise squared prediction errors.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If ``batch.targets`` is missing.
|
|
86
|
+
"""
|
|
87
|
+
if batch.targets is None:
|
|
88
|
+
raise ValueError("batch.targets must be provided.")
|
|
89
|
+
if prediction.shape != batch.targets.shape:
|
|
90
|
+
raise ValueError("prediction and batch.targets must have matching shapes.")
|
|
91
|
+
return (prediction - batch.targets) ** 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_epsilon(epsilon: float = 1e-12) -> Callable[[Array], Array]:
|
|
95
|
+
"""Create a target transform that adds a positive stabilization offset.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
epsilon: Positive finite offset added to each target.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A callable adding ``epsilon`` to an array.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If ``epsilon`` is not positive and finite.
|
|
105
|
+
"""
|
|
106
|
+
if not math.isfinite(epsilon) or epsilon <= 0.0:
|
|
107
|
+
raise ValueError("epsilon must be positive and finite.")
|
|
108
|
+
|
|
109
|
+
def transform(targets: Array) -> Array:
|
|
110
|
+
return targets + epsilon
|
|
111
|
+
|
|
112
|
+
return transform
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Compatibility aliases for the public pre-consolidation names. Both preserve
|
|
116
|
+
# their original constructor behavior while the canonical API uses
|
|
117
|
+
# NegativeLogLikelihoodLoss.
|
|
118
|
+
GaussianNLLLoss = NegativeLogLikelihoodLoss
|
|
119
|
+
"""Alias of [`NegativeLogLikelihoodLoss`][stagereg.core.NegativeLogLikelihoodLoss].
|
|
120
|
+
|
|
121
|
+
Kept for compatibility with the pre-consolidation name; prefer the canonical name.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
BetaNLLLoss = NegativeLogLikelihoodLoss
|
|
125
|
+
"""Alias of [`NegativeLogLikelihoodLoss`][stagereg.core.NegativeLogLikelihoodLoss].
|
|
126
|
+
|
|
127
|
+
Kept for compatibility with the pre-consolidation name; prefer the canonical name. The
|
|
128
|
+
beta-NLL weighting is the `beta` argument of
|
|
129
|
+
[`NegativeLogLikelihoodLoss`][stagereg.core.NegativeLogLikelihoodLoss].
|
|
130
|
+
"""
|