polystep 0.7.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.
- polystep/__init__.py +176 -0
- polystep/_compiled.py +334 -0
- polystep/_step_blockwise.py +994 -0
- polystep/_step_momentum.py +128 -0
- polystep/_step_monolithic.py +1070 -0
- polystep/adaptive_subspace.py +762 -0
- polystep/api.py +307 -0
- polystep/ask_tell.py +164 -0
- polystep/benchmarks/__init__.py +62 -0
- polystep/benchmarks/baselines.py +604 -0
- polystep/benchmarks/rl/__init__.py +13 -0
- polystep/benchmarks/rl/cartpole.py +260 -0
- polystep/benchmarks/rl/gym_evaluator.py +304 -0
- polystep/benchmarks/rl/metrics.py +53 -0
- polystep/benchmarks/rl/policies.py +133 -0
- polystep/benchmarks/utils.py +1262 -0
- polystep/blockwise.py +549 -0
- polystep/cma.py +400 -0
- polystep/cma_subspace.py +404 -0
- polystep/cost_nn.py +633 -0
- polystep/costs.py +86 -0
- polystep/dynamics.py +138 -0
- polystep/epsilon.py +217 -0
- polystep/geometry.py +410 -0
- polystep/hybrid_subspace.py +1335 -0
- polystep/layers/__init__.py +16 -0
- polystep/layers/attention.py +243 -0
- polystep/layers/rnn.py +276 -0
- polystep/objectives/__init__.py +25 -0
- polystep/objectives/base.py +77 -0
- polystep/objectives/synthetic.py +232 -0
- polystep/optimizer.py +1405 -0
- polystep/projection/__init__.py +5 -0
- polystep/projection/sparse.py +291 -0
- polystep/py.typed +0 -0
- polystep/quadratic_model.py +285 -0
- polystep/solver.py +654 -0
- polystep/solvers/__init__.py +34 -0
- polystep/solvers/_prelude.py +129 -0
- polystep/solvers/base.py +77 -0
- polystep/solvers/greedy.py +159 -0
- polystep/solvers/kl_softmax.py +200 -0
- polystep/solvers/sinkhorn.py +522 -0
- polystep/solvers/softmax.py +151 -0
- polystep/solvers/tempered_softmax.py +97 -0
- polystep/subspace.py +694 -0
- polystep/transform.py +365 -0
- polystep-0.7.0.dist-info/METADATA +285 -0
- polystep-0.7.0.dist-info/RECORD +52 -0
- polystep-0.7.0.dist-info/WHEEL +5 -0
- polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
- polystep-0.7.0.dist-info/top_level.txt +1 -0
polystep/costs.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cost matrix computation for Sinkhorn Step.
|
|
2
|
+
|
|
3
|
+
Wraps an objective function into a cost matrix by evaluating it at
|
|
4
|
+
probe points and averaging over the probe dimension.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from typing import Callable, Optional, Union
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compute_cost_matrix(
|
|
14
|
+
objective_fn: Callable[[torch.Tensor], torch.Tensor],
|
|
15
|
+
X_probe: torch.Tensor,
|
|
16
|
+
chunk_size: Optional[int] = None,
|
|
17
|
+
) -> torch.Tensor:
|
|
18
|
+
"""Compute the cost matrix by evaluating objective at probe points.
|
|
19
|
+
|
|
20
|
+
Evaluates the objective function on all probe points and averages
|
|
21
|
+
over the probe dimension to get a (batch, num_vertices) cost matrix.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
objective_fn: Function mapping (..., dim) -> (...) costs.
|
|
25
|
+
X_probe: Probe array of shape (batch, num_vertices, num_probe, dim).
|
|
26
|
+
chunk_size: If set, evaluate in chunks to limit memory.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Cost matrix of shape (batch, num_vertices).
|
|
30
|
+
"""
|
|
31
|
+
batch, num_verts, num_probe, dim = X_probe.shape
|
|
32
|
+
|
|
33
|
+
if chunk_size is not None and chunk_size > 0:
|
|
34
|
+
# Chunked evaluation for memory efficiency
|
|
35
|
+
flat_X = X_probe.reshape(-1, dim)
|
|
36
|
+
N = flat_X.shape[0]
|
|
37
|
+
|
|
38
|
+
results = []
|
|
39
|
+
for start in range(0, N, chunk_size):
|
|
40
|
+
end = min(start + chunk_size, N)
|
|
41
|
+
chunk = flat_X[start:end]
|
|
42
|
+
results.append(objective_fn(chunk))
|
|
43
|
+
|
|
44
|
+
raw_costs_flat = torch.cat(results, dim=0)
|
|
45
|
+
else:
|
|
46
|
+
flat_X = X_probe.reshape(-1, dim)
|
|
47
|
+
raw_costs_flat = objective_fn(flat_X)
|
|
48
|
+
|
|
49
|
+
raw_costs = raw_costs_flat.reshape(batch, num_verts, num_probe)
|
|
50
|
+
cost_matrix = raw_costs.mean(dim=-1) # (batch, num_vertices)
|
|
51
|
+
|
|
52
|
+
return cost_matrix
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scale_cost_matrix(
|
|
56
|
+
cost_matrix: torch.Tensor,
|
|
57
|
+
scale_cost: Optional[Union[str, float]] = None,
|
|
58
|
+
) -> torch.Tensor:
|
|
59
|
+
"""Apply cost scaling to a cost matrix.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
cost_matrix: Raw cost matrix.
|
|
63
|
+
scale_cost: Scaling strategy ('mean', 'max_cost', or float).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Scaled cost matrix.
|
|
67
|
+
"""
|
|
68
|
+
if scale_cost is None:
|
|
69
|
+
return cost_matrix
|
|
70
|
+
|
|
71
|
+
if scale_cost == "mean":
|
|
72
|
+
s = torch.clamp(cost_matrix.abs().mean(), min=1e-10)
|
|
73
|
+
return cost_matrix / s
|
|
74
|
+
elif scale_cost == "max_cost":
|
|
75
|
+
s = torch.clamp(cost_matrix.abs().max(), min=1e-10)
|
|
76
|
+
return cost_matrix / s
|
|
77
|
+
elif isinstance(scale_cost, (int, float)):
|
|
78
|
+
s = float(scale_cost)
|
|
79
|
+
if not math.isfinite(s) or s <= 0.0:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Numeric scale_cost must be finite and positive, got {scale_cost!r}. "
|
|
82
|
+
"A negative divisor flips the cost sign and reverses the objective."
|
|
83
|
+
)
|
|
84
|
+
return cost_matrix / s
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(f"Unknown scale_cost: {scale_cost!r}. Expected 'mean', 'max_cost', or a float.")
|
polystep/dynamics.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Momentum and adaptive radius dynamics for Sinkhorn Step optimization.
|
|
2
|
+
|
|
3
|
+
Pure functions that compute momentum coefficients, apply velocity updates,
|
|
4
|
+
and adapt the step radius based on loss stagnation. These are composed by
|
|
5
|
+
``PolyStepOptimizer`` (via ``use_momentum`` and ``use_adaptive_radius``
|
|
6
|
+
parameters) but kept standalone for testability.
|
|
7
|
+
|
|
8
|
+
**Momentum** accumulates a velocity vector across optimization steps, similar
|
|
9
|
+
to SGD momentum but applied in the OT particle space. The OT barycentric
|
|
10
|
+
projection gives a displacement; momentum blends this with accumulated
|
|
11
|
+
velocity to smooth the trajectory and escape local minima.
|
|
12
|
+
|
|
13
|
+
**Adaptive radius** automatically adjusts the step size based on optimization
|
|
14
|
+
progress. When the loss stagnates (small relative change for several steps),
|
|
15
|
+
the radius is increased to explore a wider region. When the loss is improving,
|
|
16
|
+
the radius is decreased to exploit the current basin.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from typing import Tuple
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute_momentum_coefficient(
|
|
26
|
+
iteration: int,
|
|
27
|
+
max_iterations: int,
|
|
28
|
+
momentum_init: float = 0.5,
|
|
29
|
+
momentum_final: float = 0.95,
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Linearly warm up momentum coefficient from init to final.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
iteration: Current iteration index (0-based).
|
|
35
|
+
max_iterations: Total number of iterations.
|
|
36
|
+
momentum_init: Starting momentum value.
|
|
37
|
+
momentum_final: Final momentum value.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Interpolated momentum coefficient.
|
|
41
|
+
"""
|
|
42
|
+
progress = min(1.0, iteration / max(1, max_iterations - 1))
|
|
43
|
+
return momentum_init + progress * (momentum_final - momentum_init)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@torch.inference_mode()
|
|
47
|
+
def apply_momentum(
|
|
48
|
+
X_old: torch.Tensor,
|
|
49
|
+
X_barycentric: torch.Tensor,
|
|
50
|
+
velocity: torch.Tensor,
|
|
51
|
+
beta: float,
|
|
52
|
+
velocity_lr: float = 1.0,
|
|
53
|
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
54
|
+
"""Apply momentum update to particles.
|
|
55
|
+
|
|
56
|
+
Computes the displacement from the OT barycentric projection, blends it
|
|
57
|
+
with the accumulated velocity, and returns the updated positions and
|
|
58
|
+
velocity.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
X_old: Particle positions before the OT step, shape (N, D).
|
|
62
|
+
X_barycentric: Particle positions from barycentric projection, shape (N, D).
|
|
63
|
+
velocity: Previous velocity tensor, shape (N, D).
|
|
64
|
+
beta: Momentum coefficient (0 = no momentum, 1 = full momentum).
|
|
65
|
+
velocity_lr: Learning rate applied to the velocity update.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Tuple of (X_new, velocity_new) where X_new are the updated positions
|
|
69
|
+
and velocity_new is the updated velocity tensor.
|
|
70
|
+
"""
|
|
71
|
+
displacement = X_barycentric - X_old
|
|
72
|
+
velocity_new = beta * velocity + displacement
|
|
73
|
+
X_new = X_old + velocity_lr * velocity_new
|
|
74
|
+
return X_new, velocity_new
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@torch.inference_mode()
|
|
78
|
+
def update_adaptive_radius(
|
|
79
|
+
current_loss: float,
|
|
80
|
+
prev_loss: float,
|
|
81
|
+
stagnation_count: int,
|
|
82
|
+
radius_multiplier: float,
|
|
83
|
+
stagnation_threshold: float = 1e-4,
|
|
84
|
+
stagnation_patience: int = 10,
|
|
85
|
+
radius_increase: float = 1.5,
|
|
86
|
+
radius_decrease: float = 0.9,
|
|
87
|
+
radius_min: float = 0.5,
|
|
88
|
+
radius_max: float = 3.0,
|
|
89
|
+
) -> Tuple[float, int, float]:
|
|
90
|
+
"""Update adaptive radius based on loss progress.
|
|
91
|
+
|
|
92
|
+
Tracks stagnation (small relative loss change) and adjusts the radius
|
|
93
|
+
multiplier: increases on prolonged stagnation to explore more, decreases
|
|
94
|
+
on improvement to exploit.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
current_loss: Loss at current iteration.
|
|
98
|
+
prev_loss: Loss at previous iteration.
|
|
99
|
+
stagnation_count: Current consecutive stagnation count.
|
|
100
|
+
radius_multiplier: Current radius multiplier.
|
|
101
|
+
stagnation_threshold: Relative change below which iteration is stagnating.
|
|
102
|
+
stagnation_patience: Stagnation iterations before radius boost.
|
|
103
|
+
radius_increase: Multiplicative factor for radius boost.
|
|
104
|
+
radius_decrease: Multiplicative factor for radius decay on improvement.
|
|
105
|
+
radius_min: Minimum allowed radius multiplier.
|
|
106
|
+
radius_max: Maximum allowed radius multiplier.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Tuple of (radius_multiplier, stagnation_count, current_loss) where
|
|
110
|
+
current_loss is returned so the caller can store it as prev_loss.
|
|
111
|
+
"""
|
|
112
|
+
# Guard against NaN/inf: if the loss is not finite, skip all radius
|
|
113
|
+
# adaptation and return the current state unchanged. This prevents a
|
|
114
|
+
# NaN loss from resetting the stagnation counter or corrupting the
|
|
115
|
+
# radius multiplier.
|
|
116
|
+
if not math.isfinite(current_loss):
|
|
117
|
+
return (radius_multiplier, stagnation_count, current_loss)
|
|
118
|
+
|
|
119
|
+
# Skip adaptation on the first step (prev_loss=inf) - no history to compare
|
|
120
|
+
if not math.isfinite(prev_loss):
|
|
121
|
+
return (radius_multiplier, stagnation_count, current_loss)
|
|
122
|
+
|
|
123
|
+
rel_change = abs(current_loss - prev_loss) / (abs(prev_loss) + 1e-10)
|
|
124
|
+
|
|
125
|
+
if rel_change < stagnation_threshold:
|
|
126
|
+
stagnation_count += 1
|
|
127
|
+
else:
|
|
128
|
+
stagnation_count = 0
|
|
129
|
+
|
|
130
|
+
if stagnation_count >= stagnation_patience:
|
|
131
|
+
radius_multiplier *= radius_increase
|
|
132
|
+
radius_multiplier = min(radius_multiplier, radius_max)
|
|
133
|
+
stagnation_count = 0
|
|
134
|
+
elif current_loss < prev_loss:
|
|
135
|
+
radius_multiplier *= radius_decrease
|
|
136
|
+
radius_multiplier = max(radius_multiplier, radius_min)
|
|
137
|
+
|
|
138
|
+
return (radius_multiplier, stagnation_count, current_loss)
|
polystep/epsilon.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Epsilon schedulers for entropic regularization decay.
|
|
2
|
+
|
|
3
|
+
Entropic regularization (epsilon) controls the smoothness of the optimal
|
|
4
|
+
transport plan. Higher epsilon gives a smoother, more diffuse plan (easier
|
|
5
|
+
to solve but less precise). Lower epsilon gives a sharper plan (closer to
|
|
6
|
+
exact OT but harder to solve numerically). Annealing from high to low
|
|
7
|
+
epsilon allows coarse-to-fine optimization.
|
|
8
|
+
|
|
9
|
+
Provides three schedulers:
|
|
10
|
+
|
|
11
|
+
- ``LinearEpsilon``: Fixed linear decay: ``eps_t = max(init - decay * t, target)``.
|
|
12
|
+
- ``CosineEpsilon``: Cosine annealing with optional SGDR-style warm restarts.
|
|
13
|
+
- ``ProgressiveEpsilon``: Feedback-driven auto-adjustment based on Sinkhorn
|
|
14
|
+
solver convergence, inspired by ProgOT (Kassraie, Pooladian, Klein,
|
|
15
|
+
Thornton, Niles-Weed & Cuturi, *Progressive Entropic Optimal Transport
|
|
16
|
+
Solvers*, NeurIPS 2024, arXiv:2406.05061).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LinearEpsilon:
|
|
26
|
+
"""Linearly decaying epsilon scheduler.
|
|
27
|
+
|
|
28
|
+
Computes epsilon at step t as:
|
|
29
|
+
epsilon(t) = max(init - decay * t, target)
|
|
30
|
+
|
|
31
|
+
The schedule starts at ``init`` and decreases by ``decay`` per iteration
|
|
32
|
+
until it reaches the ``target`` floor. This enables coarse-to-fine
|
|
33
|
+
optimization: early iterations use high epsilon for broad exploration,
|
|
34
|
+
later iterations use low epsilon for precise refinement.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
target: Floor value for epsilon.
|
|
38
|
+
init: Starting value at iteration 0.
|
|
39
|
+
decay: Amount to subtract per iteration.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
target: float = 1e-3
|
|
43
|
+
init: float = 1.0
|
|
44
|
+
decay: float = 0.01
|
|
45
|
+
|
|
46
|
+
def at(self, iteration: Optional[int] = 1) -> float:
|
|
47
|
+
"""Compute epsilon at the given iteration.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
iteration: Current iteration number.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Epsilon value (float).
|
|
54
|
+
"""
|
|
55
|
+
if iteration is None:
|
|
56
|
+
# Not yet started - return initial epsilon, not target
|
|
57
|
+
return self.init
|
|
58
|
+
eps = self.init - (self.decay * iteration)
|
|
59
|
+
return max(eps, self.target)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ProgressiveEpsilon:
|
|
64
|
+
"""Auto-adjusting epsilon based on Sinkhorn solver feedback.
|
|
65
|
+
|
|
66
|
+
Inspired by ProgOT (Kassraie et al., NeurIPS 2024, arXiv:2406.05061):
|
|
67
|
+
epsilon is adjusted from actual solver convergence behavior rather than
|
|
68
|
+
a fixed schedule.
|
|
69
|
+
|
|
70
|
+
When the solver converges quickly (few iterations), epsilon is decreased
|
|
71
|
+
to sharpen the transport plan. When the solver struggles (many iterations
|
|
72
|
+
or fails to converge), epsilon is increased to stabilize solving.
|
|
73
|
+
|
|
74
|
+
The ``at()`` method is a drop-in replacement for ``LinearEpsilon.at()``,
|
|
75
|
+
but ignores the ``iteration`` parameter. Instead, epsilon is driven by
|
|
76
|
+
explicit ``update()`` calls from the optimizer after each OT solve.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
init: Starting epsilon value.
|
|
80
|
+
target: Floor value (minimum epsilon).
|
|
81
|
+
max_epsilon: Ceiling value (maximum epsilon).
|
|
82
|
+
increase_factor: Multiplicative increase when solver struggles.
|
|
83
|
+
decrease_factor: Multiplicative decrease when solver converges fast.
|
|
84
|
+
fast_threshold: n_iters below this fraction of max_iterations triggers decrease.
|
|
85
|
+
slow_threshold: n_iters above this fraction of max_iterations triggers increase.
|
|
86
|
+
ema_alpha: EMA smoothing for epsilon changes (0 = no smoothing, 1 = no change).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
init: float = 1.0
|
|
90
|
+
target: float = 0.01
|
|
91
|
+
max_epsilon: float = 5.0
|
|
92
|
+
increase_factor: float = 1.2
|
|
93
|
+
decrease_factor: float = 0.95
|
|
94
|
+
fast_threshold: float = 0.1
|
|
95
|
+
slow_threshold: float = 0.5
|
|
96
|
+
ema_alpha: float = 0.7
|
|
97
|
+
|
|
98
|
+
# Internal state (not part of constructor signature for users)
|
|
99
|
+
_current: float = field(init=False, repr=False)
|
|
100
|
+
_smoothed: float = field(init=False, repr=False)
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
self._current = self.init
|
|
104
|
+
self._smoothed = self.init
|
|
105
|
+
|
|
106
|
+
def at(self, iteration: Optional[int] = None) -> float:
|
|
107
|
+
"""Return current epsilon value. Compatible with LinearEpsilon interface.
|
|
108
|
+
|
|
109
|
+
The ``iteration`` parameter is accepted for API compatibility with
|
|
110
|
+
``LinearEpsilon`` but is ignored. ProgressiveEpsilon is driven by
|
|
111
|
+
explicit ``update()`` calls, not iteration count.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
iteration: Ignored. Present for API compatibility.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Current smoothed epsilon value (float).
|
|
118
|
+
"""
|
|
119
|
+
return self._smoothed
|
|
120
|
+
|
|
121
|
+
def update(self, n_iters: int, max_iterations: int, converged: bool) -> None:
|
|
122
|
+
"""Update epsilon based on solver feedback.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
n_iters: Number of Sinkhorn iterations used in the last solve.
|
|
126
|
+
max_iterations: Maximum iterations the solver was allowed.
|
|
127
|
+
converged: Whether the solver converged.
|
|
128
|
+
"""
|
|
129
|
+
ratio = n_iters / max(max_iterations, 1)
|
|
130
|
+
|
|
131
|
+
if not converged or ratio > self.slow_threshold:
|
|
132
|
+
# Solver struggling: increase epsilon
|
|
133
|
+
self._current = min(self._current * self.increase_factor, self.max_epsilon)
|
|
134
|
+
elif ratio < self.fast_threshold:
|
|
135
|
+
# Solver converging fast: decrease epsilon
|
|
136
|
+
self._current = max(self._current * self.decrease_factor, self.target)
|
|
137
|
+
# else: ratio in [fast_threshold, slow_threshold] -- keep current
|
|
138
|
+
|
|
139
|
+
# EMA smooth
|
|
140
|
+
self._smoothed = self.ema_alpha * self._smoothed + (1.0 - self.ema_alpha) * self._current
|
|
141
|
+
self._smoothed = max(self._smoothed, self.target)
|
|
142
|
+
self._smoothed = min(self._smoothed, self.max_epsilon)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class CosineEpsilon:
|
|
147
|
+
"""Cosine-annealed epsilon scheduler.
|
|
148
|
+
|
|
149
|
+
Computes epsilon at step t using cosine annealing:
|
|
150
|
+
epsilon(t) = target + 0.5 * (init - target) * (1 + cos(pi * t / T))
|
|
151
|
+
|
|
152
|
+
where T = total_steps (computed from init/target/decay for API compatibility
|
|
153
|
+
with LinearEpsilon, or set explicitly via total_steps).
|
|
154
|
+
|
|
155
|
+
Cosine annealing keeps epsilon higher for longer in the middle of training
|
|
156
|
+
(encouraging exploration) then decays rapidly at the end (encouraging
|
|
157
|
+
exploitation). This typically improves convergence versus linear decay.
|
|
158
|
+
|
|
159
|
+
Supports optional warm restarts (SGDR-style): epsilon periodically resets
|
|
160
|
+
to init, with each period multiplied by ``restart_mult``.
|
|
161
|
+
|
|
162
|
+
Attributes:
|
|
163
|
+
target: Floor value for epsilon.
|
|
164
|
+
init: Starting value at iteration 0.
|
|
165
|
+
decay: Per-step linear decay rate (used to infer total_steps if
|
|
166
|
+
total_steps is not set: T = (init - target) / decay).
|
|
167
|
+
total_steps: Explicit total step count. Overrides decay-based inference.
|
|
168
|
+
restart_mult: Period multiplier for warm restarts (1.0 = no restarts).
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
target: float = 1e-3
|
|
172
|
+
init: float = 1.0
|
|
173
|
+
decay: float = 0.01
|
|
174
|
+
total_steps: int = 0
|
|
175
|
+
restart_mult: float = 1.0
|
|
176
|
+
|
|
177
|
+
def at(self, iteration: Optional[int] = 1) -> float:
|
|
178
|
+
"""Compute epsilon at the given iteration.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
iteration: Current iteration number.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Epsilon value (float).
|
|
185
|
+
"""
|
|
186
|
+
if iteration is None:
|
|
187
|
+
return self.init
|
|
188
|
+
|
|
189
|
+
T = (
|
|
190
|
+
self.total_steps
|
|
191
|
+
if self.total_steps > 0
|
|
192
|
+
else max(1, int((self.init - self.target) / max(self.decay, 1e-12)))
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if self.restart_mult > 1.0:
|
|
196
|
+
# Warm restart: find which period we're in
|
|
197
|
+
period = T
|
|
198
|
+
t = iteration
|
|
199
|
+
# Guard: limit iterations to prevent unbounded loop when
|
|
200
|
+
# restart_mult is very close to 1.0 or period is tiny
|
|
201
|
+
max_restarts = 100
|
|
202
|
+
restarts = 0
|
|
203
|
+
while t >= period and period > 0 and restarts < max_restarts:
|
|
204
|
+
t -= period
|
|
205
|
+
period = int(period * self.restart_mult)
|
|
206
|
+
restarts += 1
|
|
207
|
+
T_local = max(period, 1)
|
|
208
|
+
t_local = t
|
|
209
|
+
else:
|
|
210
|
+
T_local = T
|
|
211
|
+
t_local = min(iteration, T)
|
|
212
|
+
|
|
213
|
+
# Clamp so a maxed-out restart loop can't push cos past pi (which would
|
|
214
|
+
# drift epsilon outside [target, init]).
|
|
215
|
+
t_local = min(max(t_local, 0), T_local)
|
|
216
|
+
cos_val = math.cos(math.pi * t_local / max(T_local, 1))
|
|
217
|
+
return self.target + 0.5 * (self.init - self.target) * (1.0 + cos_val)
|