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.
Files changed (52) hide show
  1. polystep/__init__.py +176 -0
  2. polystep/_compiled.py +334 -0
  3. polystep/_step_blockwise.py +994 -0
  4. polystep/_step_momentum.py +128 -0
  5. polystep/_step_monolithic.py +1070 -0
  6. polystep/adaptive_subspace.py +762 -0
  7. polystep/api.py +307 -0
  8. polystep/ask_tell.py +164 -0
  9. polystep/benchmarks/__init__.py +62 -0
  10. polystep/benchmarks/baselines.py +604 -0
  11. polystep/benchmarks/rl/__init__.py +13 -0
  12. polystep/benchmarks/rl/cartpole.py +260 -0
  13. polystep/benchmarks/rl/gym_evaluator.py +304 -0
  14. polystep/benchmarks/rl/metrics.py +53 -0
  15. polystep/benchmarks/rl/policies.py +133 -0
  16. polystep/benchmarks/utils.py +1262 -0
  17. polystep/blockwise.py +549 -0
  18. polystep/cma.py +400 -0
  19. polystep/cma_subspace.py +404 -0
  20. polystep/cost_nn.py +633 -0
  21. polystep/costs.py +86 -0
  22. polystep/dynamics.py +138 -0
  23. polystep/epsilon.py +217 -0
  24. polystep/geometry.py +410 -0
  25. polystep/hybrid_subspace.py +1335 -0
  26. polystep/layers/__init__.py +16 -0
  27. polystep/layers/attention.py +243 -0
  28. polystep/layers/rnn.py +276 -0
  29. polystep/objectives/__init__.py +25 -0
  30. polystep/objectives/base.py +77 -0
  31. polystep/objectives/synthetic.py +232 -0
  32. polystep/optimizer.py +1405 -0
  33. polystep/projection/__init__.py +5 -0
  34. polystep/projection/sparse.py +291 -0
  35. polystep/py.typed +0 -0
  36. polystep/quadratic_model.py +285 -0
  37. polystep/solver.py +654 -0
  38. polystep/solvers/__init__.py +34 -0
  39. polystep/solvers/_prelude.py +129 -0
  40. polystep/solvers/base.py +77 -0
  41. polystep/solvers/greedy.py +159 -0
  42. polystep/solvers/kl_softmax.py +200 -0
  43. polystep/solvers/sinkhorn.py +522 -0
  44. polystep/solvers/softmax.py +151 -0
  45. polystep/solvers/tempered_softmax.py +97 -0
  46. polystep/subspace.py +694 -0
  47. polystep/transform.py +365 -0
  48. polystep-0.7.0.dist-info/METADATA +285 -0
  49. polystep-0.7.0.dist-info/RECORD +52 -0
  50. polystep-0.7.0.dist-info/WHEEL +5 -0
  51. polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
  52. 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)