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/api.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""High-level training API for polystep.
|
|
2
|
+
|
|
3
|
+
Provides a single-call ``train()`` function that wraps PolyStepOptimizer
|
|
4
|
+
with an epoch-based training loop, callback system, and diagnostics.
|
|
5
|
+
Users can train any ``nn.Module`` without manually managing closures,
|
|
6
|
+
epoch iteration, or batch handling.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable, List, Optional
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn as nn
|
|
16
|
+
from torch.utils.data import DataLoader
|
|
17
|
+
|
|
18
|
+
from .cost_nn import NNCostEvaluator
|
|
19
|
+
from .optimizer import PolyStepOptimizer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrainCallback:
|
|
23
|
+
"""Base class for training callbacks.
|
|
24
|
+
|
|
25
|
+
Subclass and override ``on_step_end`` and/or ``on_epoch_end`` to
|
|
26
|
+
hook into the training loop. Return ``True`` from ``on_step_end``
|
|
27
|
+
to signal early stopping.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
class LossThresholdCallback(TrainCallback):
|
|
32
|
+
def __init__(self, threshold: float = 0.01):
|
|
33
|
+
self.threshold = threshold
|
|
34
|
+
|
|
35
|
+
def on_step_end(self, metrics: dict) -> bool:
|
|
36
|
+
if metrics['loss'] < self.threshold:
|
|
37
|
+
print(f"Target loss reached at step {metrics['step']}")
|
|
38
|
+
return True # stop training
|
|
39
|
+
return False
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def on_step_end(self, metrics: dict) -> bool:
|
|
43
|
+
"""Called after each optimizer step.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
metrics: Dict with keys 'step', 'epoch', 'loss', 'ot_cost',
|
|
47
|
+
'displacement', 'velocity_mag', 'converged'.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True to stop training, False to continue.
|
|
51
|
+
"""
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def on_epoch_end(self, metrics: dict) -> None:
|
|
55
|
+
"""Called after each epoch completes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
metrics: Dict with keys 'epoch', 'avg_loss'.
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class TrainConfig:
|
|
65
|
+
"""Configuration for the ``train()`` function.
|
|
66
|
+
|
|
67
|
+
Training loop parameters only. Optimizer-specific parameters (epsilon,
|
|
68
|
+
step_radius, subspace, block_strategy, etc.) are set on
|
|
69
|
+
``PolyStepOptimizer``.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
epochs: Number of training epochs. Must be > 0.
|
|
73
|
+
batch_size: Convenience/documentation field. ``train()`` uses
|
|
74
|
+
the provided DataLoader directly and does NOT build one
|
|
75
|
+
from this value.
|
|
76
|
+
log_every: Step interval for built-in logging. Must be > 0.
|
|
77
|
+
callbacks: List of ``TrainCallback`` instances. ``None`` is
|
|
78
|
+
normalized to an empty list.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
epochs: int = 10
|
|
82
|
+
batch_size: int = 32
|
|
83
|
+
log_every: int = 10
|
|
84
|
+
callbacks: Optional[List[TrainCallback]] = None
|
|
85
|
+
|
|
86
|
+
def __post_init__(self):
|
|
87
|
+
if self.epochs <= 0:
|
|
88
|
+
raise ValueError(f"epochs must be > 0, got {self.epochs}")
|
|
89
|
+
if self.batch_size <= 0:
|
|
90
|
+
raise ValueError(f"batch_size must be > 0, got {self.batch_size}")
|
|
91
|
+
if self.log_every <= 0:
|
|
92
|
+
raise ValueError(f"log_every must be > 0, got {self.log_every}")
|
|
93
|
+
if self.callbacks is None:
|
|
94
|
+
self.callbacks = []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def train(
|
|
98
|
+
model: nn.Module,
|
|
99
|
+
dataloader: DataLoader,
|
|
100
|
+
loss_fn: Callable,
|
|
101
|
+
optimizer: PolyStepOptimizer,
|
|
102
|
+
config: TrainConfig,
|
|
103
|
+
) -> nn.Module:
|
|
104
|
+
"""Train a model using the Sinkhorn Step optimizer.
|
|
105
|
+
|
|
106
|
+
Builds the OT closure internally from the model, loss function, and
|
|
107
|
+
each mini-batch. Users never handle closures directly. For each batch,
|
|
108
|
+
the function constructs a closure that evaluates the model at multiple
|
|
109
|
+
candidate parameter configurations via ``NNCostEvaluator``, then calls
|
|
110
|
+
``optimizer.step(closure)`` to update the model weights.
|
|
111
|
+
|
|
112
|
+
Example::
|
|
113
|
+
|
|
114
|
+
from polystep import PolyStepOptimizer, train, TrainConfig, LoggingCallback
|
|
115
|
+
|
|
116
|
+
optimizer = PolyStepOptimizer(model, epsilon=0.1)
|
|
117
|
+
config = TrainConfig(epochs=5, callbacks=[LoggingCallback()])
|
|
118
|
+
trained_model = train(model, dataloader, loss_fn, optimizer, config)
|
|
119
|
+
|
|
120
|
+
See Also:
|
|
121
|
+
``PolyStepOptimizer`` for optimizer configuration (epsilon,
|
|
122
|
+
step_radius, subspace, block_strategy, etc.).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
model: The ``nn.Module`` to train. Updated in-place.
|
|
126
|
+
dataloader: PyTorch DataLoader yielding ``(inputs, targets)`` batches.
|
|
127
|
+
loss_fn: Loss function with signature ``loss_fn(output, targets) -> scalar``.
|
|
128
|
+
optimizer: A ``PolyStepOptimizer`` instance (already configured).
|
|
129
|
+
config: ``TrainConfig`` with loop hyperparameters and callbacks.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The same model object (mutated in-place by the optimizer).
|
|
133
|
+
"""
|
|
134
|
+
evaluator = NNCostEvaluator(
|
|
135
|
+
model,
|
|
136
|
+
loss_fn=loss_fn,
|
|
137
|
+
compile_vmap=getattr(optimizer, "_compile_evaluator", False),
|
|
138
|
+
)
|
|
139
|
+
callbacks = list(config.callbacks)
|
|
140
|
+
global_step = 0
|
|
141
|
+
stop = False
|
|
142
|
+
|
|
143
|
+
# Detect model device for automatic batch transfer
|
|
144
|
+
try:
|
|
145
|
+
device = next(model.parameters()).device
|
|
146
|
+
except StopIteration:
|
|
147
|
+
raise ValueError("Model has no trainable parameters")
|
|
148
|
+
|
|
149
|
+
for epoch in range(config.epochs):
|
|
150
|
+
epoch_loss_sum = 0.0
|
|
151
|
+
epoch_loss_count = 0
|
|
152
|
+
|
|
153
|
+
for batch in dataloader:
|
|
154
|
+
inputs, targets = batch
|
|
155
|
+
inputs, targets = inputs.to(device), targets.to(device)
|
|
156
|
+
|
|
157
|
+
# Micro-batch: subsample for cost evaluation if configured.
|
|
158
|
+
cost_bs = getattr(optimizer, "cost_batch_size", None)
|
|
159
|
+
if cost_bs is not None and cost_bs < inputs.shape[0]:
|
|
160
|
+
gen = getattr(optimizer, "_generator", None)
|
|
161
|
+
if gen is not None and gen.device.type == inputs.device.type:
|
|
162
|
+
idx = torch.randperm(inputs.shape[0], device=inputs.device, generator=gen)[:cost_bs]
|
|
163
|
+
else:
|
|
164
|
+
idx = torch.randperm(inputs.shape[0], device=inputs.device)[:cost_bs]
|
|
165
|
+
cost_inputs = inputs[idx]
|
|
166
|
+
cost_targets = targets[idx]
|
|
167
|
+
else:
|
|
168
|
+
cost_inputs = inputs
|
|
169
|
+
cost_targets = targets
|
|
170
|
+
|
|
171
|
+
# Build closure for this batch using default-argument capture
|
|
172
|
+
def closure(batched_params, _in=cost_inputs, _tgt=cost_targets):
|
|
173
|
+
return evaluator.evaluate(batched_params, _in, _tgt)
|
|
174
|
+
|
|
175
|
+
optimizer.step(closure)
|
|
176
|
+
|
|
177
|
+
# Compute training loss separately (not the OT cost)
|
|
178
|
+
with torch.no_grad():
|
|
179
|
+
output = model(inputs)
|
|
180
|
+
train_loss = loss_fn(output, targets).item()
|
|
181
|
+
|
|
182
|
+
epoch_loss_sum += train_loss
|
|
183
|
+
epoch_loss_count += 1
|
|
184
|
+
|
|
185
|
+
# Build metrics dict
|
|
186
|
+
state = optimizer.state
|
|
187
|
+
metrics = {
|
|
188
|
+
"step": global_step,
|
|
189
|
+
"epoch": epoch,
|
|
190
|
+
"loss": train_loss,
|
|
191
|
+
"ot_cost": state.costs[-1] if state.costs else 0.0,
|
|
192
|
+
"displacement": (state.displacement_sqnorms[-1] if state.displacement_sqnorms else 0.0),
|
|
193
|
+
"velocity_mag": (torch.norm(state.velocity).item() if state.velocity is not None else 0.0),
|
|
194
|
+
"converged": (state.linear_convergence[-1] if state.linear_convergence else False),
|
|
195
|
+
"absorb_count": getattr(state, "absorb_count", 0),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Invoke on_step_end callbacks
|
|
199
|
+
for cb in callbacks:
|
|
200
|
+
if cb.on_step_end(metrics):
|
|
201
|
+
stop = True
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
if stop:
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
global_step += 1
|
|
208
|
+
|
|
209
|
+
if stop:
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
# Epoch-level metrics
|
|
213
|
+
avg_loss = epoch_loss_sum / epoch_loss_count if epoch_loss_count > 0 else 0.0
|
|
214
|
+
epoch_metrics = {
|
|
215
|
+
"epoch": epoch,
|
|
216
|
+
"avg_loss": avg_loss,
|
|
217
|
+
}
|
|
218
|
+
for cb in callbacks:
|
|
219
|
+
cb.on_epoch_end(epoch_metrics)
|
|
220
|
+
|
|
221
|
+
return model
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Built-in callbacks
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class LoggingCallback(TrainCallback):
|
|
230
|
+
"""Prints step metrics at a configurable interval.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
log_every: Print every *log_every* steps (default 10).
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, log_every: int = 10):
|
|
237
|
+
self.log_every = log_every
|
|
238
|
+
|
|
239
|
+
def on_step_end(self, metrics: dict) -> bool:
|
|
240
|
+
if metrics["step"] % self.log_every == 0:
|
|
241
|
+
print(
|
|
242
|
+
f"[Step {metrics['step']}] "
|
|
243
|
+
f"loss={metrics['loss']:.4f} "
|
|
244
|
+
f"ot_cost={metrics['ot_cost']:.4f} "
|
|
245
|
+
f"disp={metrics['displacement']:.6f} "
|
|
246
|
+
f"converged={metrics['converged']}"
|
|
247
|
+
)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def on_epoch_end(self, metrics: dict) -> None:
|
|
251
|
+
print(f"--- Epoch {metrics['epoch']} complete | avg_loss={metrics['avg_loss']:.4f} ---")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class EarlyStoppingCallback(TrainCallback):
|
|
255
|
+
"""Stops training when loss stagnates for *patience* steps.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
patience: Number of steps without improvement before stopping.
|
|
259
|
+
min_delta: Minimum loss decrease to count as improvement.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, patience: int = 10, min_delta: float = 1e-4):
|
|
263
|
+
self.patience = patience
|
|
264
|
+
self.min_delta = min_delta
|
|
265
|
+
self.best_loss: float = float("inf")
|
|
266
|
+
self.counter: int = 0
|
|
267
|
+
|
|
268
|
+
def on_step_end(self, metrics: dict) -> bool:
|
|
269
|
+
loss = metrics["loss"]
|
|
270
|
+
if loss < self.best_loss - self.min_delta:
|
|
271
|
+
self.best_loss = loss
|
|
272
|
+
self.counter = 0
|
|
273
|
+
else:
|
|
274
|
+
self.counter += 1
|
|
275
|
+
if self.counter >= self.patience:
|
|
276
|
+
print(f"Early stopping at step {metrics['step']}")
|
|
277
|
+
return True
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Diagnostics helper
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_diagnostics(optimizer: PolyStepOptimizer) -> dict:
|
|
287
|
+
"""Extract diagnostic summary from optimizer state.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
optimizer: A ``PolyStepOptimizer`` instance (may have been stepped).
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Dict with keys: 'costs', 'displacement_sqnorms', 'convergence',
|
|
294
|
+
'velocity_magnitude', 'iteration_count', 'epsilon', 'radius_multiplier',
|
|
295
|
+
'absorb_count'.
|
|
296
|
+
"""
|
|
297
|
+
state = optimizer.state
|
|
298
|
+
return {
|
|
299
|
+
"costs": list(state.costs),
|
|
300
|
+
"displacement_sqnorms": list(state.displacement_sqnorms),
|
|
301
|
+
"convergence": list(state.linear_convergence),
|
|
302
|
+
"velocity_magnitude": (torch.norm(state.velocity).item() if state.velocity is not None else None),
|
|
303
|
+
"iteration_count": state.iteration_count,
|
|
304
|
+
"epsilon": state.epsilon,
|
|
305
|
+
"radius_multiplier": state.radius_multiplier,
|
|
306
|
+
"absorb_count": getattr(state, "absorb_count", 0),
|
|
307
|
+
}
|
polystep/ask_tell.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""PolyStep as a standard ask/tell optimizer.
|
|
2
|
+
|
|
3
|
+
Exposes the Sinkhorn Step update behind the ``ask``/``tell`` interface used by
|
|
4
|
+
evolution-strategy libraries (evosax, NeuroEvoBench, CMA-ES). ``ask`` returns
|
|
5
|
+
candidate points (rotated polytope vertices around each particle) for the caller
|
|
6
|
+
to evaluate; ``tell`` takes their fitness (lower is better), builds the cost
|
|
7
|
+
matrix, and applies one softmax-weighted barycentric step. PolyStep is then a
|
|
8
|
+
drop-in gradient-free optimizer comparable to any ES.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import warnings
|
|
15
|
+
from typing import Callable, Optional, Union
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from ._compiled import _barycentric_projection, _rotate_and_translate
|
|
20
|
+
from .geometry import get_orthoplex_vertices, get_random_rotation_matrices
|
|
21
|
+
from .solvers import SinkhornSolver, SoftmaxSolver
|
|
22
|
+
|
|
23
|
+
__all__ = ["PolyStepES", "minimize"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PolyStepES:
|
|
27
|
+
"""Ask/tell wrapper around the Sinkhorn Step update.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
dim: Dimensionality of a solution vector.
|
|
31
|
+
num_particles: Number of independent particles (each proposes a
|
|
32
|
+
polytope of candidates). Population size is ``num_particles * 2 * dim``.
|
|
33
|
+
epsilon: Entropic OT temperature for the softmax weighting.
|
|
34
|
+
step_radius: Geometric step size along the polytope directions.
|
|
35
|
+
solver: OT solver instance. Defaults to :class:`SoftmaxSolver`; pass a
|
|
36
|
+
:class:`SinkhornSolver` for the full entropic-OT plan.
|
|
37
|
+
scale_cost: Cost-matrix scaling passed to the solver ("mean", "max_cost",
|
|
38
|
+
a float divisor, or None).
|
|
39
|
+
x0: Initial position(s), shape ``(dim,)`` or ``(num_particles, dim)``.
|
|
40
|
+
seed: Seed for the rotation generator.
|
|
41
|
+
device, dtype: Tensor device and dtype.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
dim: int,
|
|
47
|
+
num_particles: int = 1,
|
|
48
|
+
epsilon: float = 0.5,
|
|
49
|
+
step_radius: float = 0.5,
|
|
50
|
+
solver: Optional[Union[SoftmaxSolver, SinkhornSolver]] = None,
|
|
51
|
+
scale_cost: Optional[Union[str, float]] = "mean",
|
|
52
|
+
x0: Optional[torch.Tensor] = None,
|
|
53
|
+
seed: Optional[int] = None,
|
|
54
|
+
device: Union[str, torch.device] = "cpu",
|
|
55
|
+
dtype: torch.dtype = torch.float32,
|
|
56
|
+
):
|
|
57
|
+
if dim < 1:
|
|
58
|
+
raise ValueError(f"dim must be >= 1, got {dim}.")
|
|
59
|
+
if num_particles < 1:
|
|
60
|
+
raise ValueError(f"num_particles must be >= 1, got {num_particles}.")
|
|
61
|
+
if not epsilon > 0:
|
|
62
|
+
raise ValueError(f"epsilon must be > 0, got {epsilon}.")
|
|
63
|
+
if not (math.isfinite(step_radius) and step_radius >= 0):
|
|
64
|
+
raise ValueError(f"step_radius must be finite and >= 0, got {step_radius}.")
|
|
65
|
+
self.dim = dim
|
|
66
|
+
self.num_particles = num_particles
|
|
67
|
+
self.epsilon = epsilon
|
|
68
|
+
self.step_radius = step_radius
|
|
69
|
+
self.scale_cost = scale_cost
|
|
70
|
+
self.device = torch.device(device)
|
|
71
|
+
self.dtype = dtype
|
|
72
|
+
self.solver = solver if solver is not None else SoftmaxSolver(epsilon=epsilon)
|
|
73
|
+
if isinstance(self.solver, SinkhornSolver) and num_particles == 1:
|
|
74
|
+
warnings.warn(
|
|
75
|
+
"SinkhornSolver with num_particles=1 yields a uniform transport plan "
|
|
76
|
+
"(the column marginal forces it), so steps ignore fitness. Use the "
|
|
77
|
+
"default SoftmaxSolver, or num_particles > 1.",
|
|
78
|
+
stacklevel=2,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.generator = torch.Generator(device=self.device)
|
|
82
|
+
if seed is not None:
|
|
83
|
+
self.generator.manual_seed(seed)
|
|
84
|
+
|
|
85
|
+
self.vertices = get_orthoplex_vertices(dim, device=self.device, dtype=dtype) # (2d, d)
|
|
86
|
+
self.num_vertices = self.vertices.shape[0]
|
|
87
|
+
|
|
88
|
+
if x0 is None:
|
|
89
|
+
X = torch.zeros(num_particles, dim, device=self.device, dtype=dtype)
|
|
90
|
+
else:
|
|
91
|
+
X = torch.as_tensor(x0, device=self.device, dtype=dtype).reshape(-1, dim)
|
|
92
|
+
if X.shape[0] == 1 and num_particles > 1:
|
|
93
|
+
X = X.expand(num_particles, dim).clone()
|
|
94
|
+
if X.shape[0] != num_particles:
|
|
95
|
+
raise ValueError(f"x0 has {X.shape[0]} particle rows but num_particles={num_particles}.")
|
|
96
|
+
self.X = X.clone()
|
|
97
|
+
self.a = torch.full((num_particles,), 1.0 / num_particles, device=self.device, dtype=dtype)
|
|
98
|
+
|
|
99
|
+
self._pending: Optional[torch.Tensor] = None
|
|
100
|
+
self.best_solution: Optional[torch.Tensor] = None
|
|
101
|
+
self.best_fitness: float = float("inf")
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def popsize(self) -> int:
|
|
105
|
+
return self.num_particles * self.num_vertices
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def mean(self) -> torch.Tensor:
|
|
109
|
+
"""Mean particle position (the current solution estimate)."""
|
|
110
|
+
return self.X.mean(dim=0)
|
|
111
|
+
|
|
112
|
+
@torch.inference_mode()
|
|
113
|
+
def ask(self) -> torch.Tensor:
|
|
114
|
+
"""Return candidate points of shape ``(popsize, dim)`` to evaluate."""
|
|
115
|
+
if self._pending is not None:
|
|
116
|
+
raise RuntimeError("ask() called twice before tell(); tell() the previous population first.")
|
|
117
|
+
rot = get_random_rotation_matrices(
|
|
118
|
+
self.num_particles, self.dim, device=self.device, dtype=self.dtype, generator=self.generator
|
|
119
|
+
)
|
|
120
|
+
X_vertices, _ = _rotate_and_translate(rot, self.vertices, self.X, self.step_radius) # (P, V, d)
|
|
121
|
+
self._pending = X_vertices
|
|
122
|
+
return X_vertices.reshape(self.popsize, self.dim)
|
|
123
|
+
|
|
124
|
+
@torch.inference_mode()
|
|
125
|
+
def tell(self, fitness: torch.Tensor) -> None:
|
|
126
|
+
"""Update particles from the fitness of the last ``ask`` (lower is better)."""
|
|
127
|
+
if self._pending is None:
|
|
128
|
+
raise RuntimeError("tell() called before ask()")
|
|
129
|
+
cost = torch.as_tensor(fitness, device=self.device, dtype=self.dtype).reshape(
|
|
130
|
+
self.num_particles, self.num_vertices
|
|
131
|
+
)
|
|
132
|
+
self.solver.epsilon = self.epsilon
|
|
133
|
+
# Source marginal defaults to uniform 1/P inside the solver, which is
|
|
134
|
+
# exactly ``self.a`` -- pass None so the solver skips the (host-syncing)
|
|
135
|
+
# user-marginal validation on this per-step path.
|
|
136
|
+
transport = self.solver.solve(cost, scale_cost=self.scale_cost).matrix
|
|
137
|
+
X_new = _barycentric_projection(transport, self.a, self._pending)
|
|
138
|
+
if torch.isfinite(X_new).all():
|
|
139
|
+
self.X = X_new
|
|
140
|
+
|
|
141
|
+
flat_cost = cost.reshape(-1)
|
|
142
|
+
fmin, idx = torch.min(flat_cost, dim=0)
|
|
143
|
+
if fmin.item() < self.best_fitness:
|
|
144
|
+
self.best_fitness = fmin.item()
|
|
145
|
+
self.best_solution = self._pending.reshape(self.popsize, self.dim)[idx].clone()
|
|
146
|
+
self._pending = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def minimize(
|
|
150
|
+
fn: Callable[[torch.Tensor], torch.Tensor],
|
|
151
|
+
dim: int,
|
|
152
|
+
steps: int = 200,
|
|
153
|
+
**kwargs,
|
|
154
|
+
) -> PolyStepES:
|
|
155
|
+
"""Minimize a batched black-box ``fn: (popsize, dim) -> (popsize,)``.
|
|
156
|
+
|
|
157
|
+
Runs ``steps`` ask/tell rounds and returns the optimizer, whose
|
|
158
|
+
``best_solution`` / ``best_fitness`` / ``mean`` hold the result.
|
|
159
|
+
"""
|
|
160
|
+
es = PolyStepES(dim, **kwargs)
|
|
161
|
+
for _ in range(steps):
|
|
162
|
+
candidates = es.ask()
|
|
163
|
+
es.tell(fn(candidates))
|
|
164
|
+
return es
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Benchmark utilities for polystep experiments.
|
|
2
|
+
|
|
3
|
+
Provides shared model factories, data loaders, baselines, and evaluation
|
|
4
|
+
helpers used by the experiment runners and examples.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .utils import (
|
|
8
|
+
SEEDS,
|
|
9
|
+
get_mnist_loaders,
|
|
10
|
+
get_cifar10_loaders,
|
|
11
|
+
get_sst2_loaders,
|
|
12
|
+
get_nmnist_loaders,
|
|
13
|
+
get_dvs_gesture_loaders,
|
|
14
|
+
MNISTNet,
|
|
15
|
+
CIFAR10Net,
|
|
16
|
+
create_model,
|
|
17
|
+
LIFNeuron,
|
|
18
|
+
SpikingNet,
|
|
19
|
+
evaluate_accuracy,
|
|
20
|
+
evaluate_snn_accuracy,
|
|
21
|
+
BenchmarkResult,
|
|
22
|
+
get_environment_info,
|
|
23
|
+
save_results_json,
|
|
24
|
+
format_results_table,
|
|
25
|
+
compute_summary_stats,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .baselines import (
|
|
29
|
+
has_evotorch,
|
|
30
|
+
has_nevergrad,
|
|
31
|
+
train_cmaes,
|
|
32
|
+
train_nevergrad,
|
|
33
|
+
check_gradient_free_deps,
|
|
34
|
+
get_available_optimizers,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"SEEDS",
|
|
39
|
+
"get_mnist_loaders",
|
|
40
|
+
"get_cifar10_loaders",
|
|
41
|
+
"get_sst2_loaders",
|
|
42
|
+
"get_nmnist_loaders",
|
|
43
|
+
"get_dvs_gesture_loaders",
|
|
44
|
+
"MNISTNet",
|
|
45
|
+
"CIFAR10Net",
|
|
46
|
+
"create_model",
|
|
47
|
+
"LIFNeuron",
|
|
48
|
+
"SpikingNet",
|
|
49
|
+
"evaluate_accuracy",
|
|
50
|
+
"evaluate_snn_accuracy",
|
|
51
|
+
"BenchmarkResult",
|
|
52
|
+
"get_environment_info",
|
|
53
|
+
"save_results_json",
|
|
54
|
+
"format_results_table",
|
|
55
|
+
"compute_summary_stats",
|
|
56
|
+
"has_evotorch",
|
|
57
|
+
"has_nevergrad",
|
|
58
|
+
"train_cmaes",
|
|
59
|
+
"train_nevergrad",
|
|
60
|
+
"check_gradient_free_deps",
|
|
61
|
+
"get_available_optimizers",
|
|
62
|
+
]
|