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/optimizer.py
ADDED
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
"""PolyStepOptimizer: user-facing wrapper with closure-based step interface.
|
|
2
|
+
|
|
3
|
+
Composes low-level geometry, compiled functions, Sinkhorn solver, and dynamics
|
|
4
|
+
(momentum + adaptive radius) into a clean step(closure) API. The optimizer
|
|
5
|
+
manages model parameter synchronization, state tracking, and optional
|
|
6
|
+
subspace/block-wise decomposition.
|
|
7
|
+
|
|
8
|
+
Multi-particle architecture: Model parameters are reshaped into
|
|
9
|
+
(num_particles, particle_dim) where particle_dim is typically 2. Each particle
|
|
10
|
+
is an independent unit in the OT problem. The polytope operates in
|
|
11
|
+
particle_dim space (e.g., 4 orthoplex vertices in 2D), giving a tractable
|
|
12
|
+
OT problem of shape (num_particles, num_vertices).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import collections
|
|
18
|
+
import logging
|
|
19
|
+
import warnings
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable, List, Optional, Tuple, TYPE_CHECKING, Union
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
import torch.nn as nn
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .cost_nn import NNCostEvaluator
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Auto-selection helper for projection type
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Minimum params for sparse projection (below this, dense is more efficient)
|
|
38
|
+
_MIN_PARAMS_FOR_SPARSE = 10_000
|
|
39
|
+
|
|
40
|
+
# Threshold for auto-selecting sparse vs dense (device-dependent)
|
|
41
|
+
_AUTO_SPARSE_THRESHOLD_CPU = 1_000_000 # 1M params for CPU
|
|
42
|
+
_AUTO_SPARSE_THRESHOLD_GPU = 2_000_000 # 2M params for GPU
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _select_projection_type(
|
|
46
|
+
num_params: int,
|
|
47
|
+
device: torch.device,
|
|
48
|
+
projection_type: str,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Determine whether to use dense or sparse projection.
|
|
51
|
+
|
|
52
|
+
Auto-selection thresholds are based on memory efficiency vs setup overhead.
|
|
53
|
+
For small models, dense projection is more efficient. For large models,
|
|
54
|
+
sparse projection reduces memory footprint significantly.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
num_params: Total model parameters.
|
|
58
|
+
device: Model device (CPU or CUDA).
|
|
59
|
+
projection_type: User preference ('dense', 'sparse', 'auto').
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
'dense' or 'sparse' (resolved projection type).
|
|
63
|
+
"""
|
|
64
|
+
# Explicit user choice
|
|
65
|
+
if projection_type in ("dense", "sparse"):
|
|
66
|
+
return projection_type
|
|
67
|
+
|
|
68
|
+
# Auto-selection based on device and model size
|
|
69
|
+
if device.type == "cuda":
|
|
70
|
+
threshold = _AUTO_SPARSE_THRESHOLD_GPU
|
|
71
|
+
else:
|
|
72
|
+
threshold = _AUTO_SPARSE_THRESHOLD_CPU
|
|
73
|
+
|
|
74
|
+
if num_params >= threshold:
|
|
75
|
+
return "sparse"
|
|
76
|
+
else:
|
|
77
|
+
return "dense"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
from ._compiled import CompiledFunctions
|
|
81
|
+
from .blockwise import (
|
|
82
|
+
BlockConfig,
|
|
83
|
+
create_grouped_blocks,
|
|
84
|
+
create_per_layer_blocks,
|
|
85
|
+
create_subspace_blocks,
|
|
86
|
+
)
|
|
87
|
+
from .epsilon import LinearEpsilon
|
|
88
|
+
from .geometry import POLYTOPE_MAP
|
|
89
|
+
from .solvers import SinkhornSolver, SoftmaxSolver, MinCostGreedySolver, TopKMeanSolver, TemperedSoftmaxSolver
|
|
90
|
+
from .solver import SolverState
|
|
91
|
+
from .adaptive_subspace import AdaptiveSubspace
|
|
92
|
+
from .cma_subspace import CMAAdaptiveSubspace
|
|
93
|
+
from .subspace import LowRankSubspace, LinearSubspace
|
|
94
|
+
from .hybrid_subspace import HybridSubspace
|
|
95
|
+
from .transform import ParamLayout, create_generator
|
|
96
|
+
|
|
97
|
+
# Extracted step methods (decomposed for maintainability)
|
|
98
|
+
from ._step_monolithic import step_monolithic as _step_monolithic_fn
|
|
99
|
+
from ._step_blockwise import step_blockwise as _step_blockwise_fn
|
|
100
|
+
from ._step_blockwise import step_subspace_blockwise as _step_subspace_blockwise_fn
|
|
101
|
+
from ._step_momentum import (
|
|
102
|
+
step_momentum as _step_momentum_fn,
|
|
103
|
+
evaluate_current_loss as _evaluate_current_loss_fn,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Rank schedule for progressive subspace expansion
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class RankSchedule:
|
|
114
|
+
"""Progressive rank expansion schedule for subspace optimization.
|
|
115
|
+
|
|
116
|
+
Maps step number to target rank. Rank increases at specified steps,
|
|
117
|
+
triggering absorb + subspace reconstruction.
|
|
118
|
+
|
|
119
|
+
Example::
|
|
120
|
+
|
|
121
|
+
schedule = RankSchedule(stages=[(0, 2), (100, 4), (300, 8)])
|
|
122
|
+
# rank=2 for steps 0-99, rank=4 for 100-299, rank=8 for 300+
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
stages: List[Tuple[int, int]] # (start_step, rank) pairs
|
|
126
|
+
|
|
127
|
+
def __post_init__(self):
|
|
128
|
+
# Sort by start_step
|
|
129
|
+
self.stages = sorted(self.stages, key=lambda x: x[0])
|
|
130
|
+
if not self.stages:
|
|
131
|
+
raise ValueError("RankSchedule requires at least one stage")
|
|
132
|
+
if self.stages[0][0] != 0:
|
|
133
|
+
raise ValueError("First stage must start at step 0")
|
|
134
|
+
for _, rank in self.stages:
|
|
135
|
+
if rank < 1:
|
|
136
|
+
raise ValueError(f"Rank must be >= 1, got {rank}")
|
|
137
|
+
|
|
138
|
+
def at(self, step: int) -> int:
|
|
139
|
+
"""Return rank at given step."""
|
|
140
|
+
current_rank = self.stages[0][1]
|
|
141
|
+
for start_step, rank in self.stages:
|
|
142
|
+
if step >= start_step:
|
|
143
|
+
current_rank = rank
|
|
144
|
+
return current_rank
|
|
145
|
+
|
|
146
|
+
def transitions(self) -> List[int]:
|
|
147
|
+
"""Return step numbers where rank changes (excluding step 0)."""
|
|
148
|
+
return [s for s, _ in self.stages if s > 0]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PolyStepOptimizer:
|
|
152
|
+
"""Gradient-free optimizer via entropic optimal transport.
|
|
153
|
+
|
|
154
|
+
Wraps polytope sampling, OT solve, barycentric projection, momentum,
|
|
155
|
+
and adaptive radius into a single ``step(closure)`` interface. Updates
|
|
156
|
+
the model weights in-place after each step.
|
|
157
|
+
|
|
158
|
+
The optimizer uses entropic regularization (controls smoothness of the
|
|
159
|
+
transport plan; higher values give smoother but less precise solutions)
|
|
160
|
+
to solve an optimal transport problem between current particle positions
|
|
161
|
+
and candidate polytope vertices. The solution yields a transport plan
|
|
162
|
+
whose weights are used for barycentric projection (updates particle
|
|
163
|
+
positions as a weighted average of polytope vertices, with weights from
|
|
164
|
+
the optimal transport plan). Between iterations, dual potentials
|
|
165
|
+
(auxiliary variables from the Sinkhorn algorithm that encode the optimal
|
|
166
|
+
transport solution) are warm-started for faster convergence.
|
|
167
|
+
|
|
168
|
+
This is a standalone class (NOT a ``torch.optim.Optimizer`` subclass)
|
|
169
|
+
because Sinkhorn Step is fundamentally gradient-free and does not use
|
|
170
|
+
parameter groups or ``zero_grad()``.
|
|
171
|
+
|
|
172
|
+
**Multi-particle architecture:** Model parameters are laid out as
|
|
173
|
+
``(num_particles, particle_dim)`` where ``particle_dim`` defaults to 2.
|
|
174
|
+
The polytope operates in ``particle_dim`` space (e.g., orthoplex in 2D
|
|
175
|
+
has 4 vertices), producing a tractable OT problem. For each probe
|
|
176
|
+
evaluation, the full model parameters are reconstructed from the
|
|
177
|
+
particle array with one row replaced by the probe position.
|
|
178
|
+
|
|
179
|
+
Example::
|
|
180
|
+
|
|
181
|
+
import torch
|
|
182
|
+
import torch.nn as nn
|
|
183
|
+
from polystep import PolyStepOptimizer
|
|
184
|
+
|
|
185
|
+
model = nn.Sequential(nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10))
|
|
186
|
+
optimizer = PolyStepOptimizer(model, epsilon=0.1, step_radius=0.15)
|
|
187
|
+
|
|
188
|
+
# Define a closure that evaluates loss at batched parameter configs
|
|
189
|
+
def closure(batched_params):
|
|
190
|
+
# batched_params: {key: (N, *shape)} -- N candidate param sets
|
|
191
|
+
# Return losses tensor of shape (N,)
|
|
192
|
+
...
|
|
193
|
+
|
|
194
|
+
cost = optimizer.step(closure)
|
|
195
|
+
|
|
196
|
+
See Also:
|
|
197
|
+
``train()`` for a high-level training loop that builds closures
|
|
198
|
+
automatically from a DataLoader and loss function.
|
|
199
|
+
``TrainConfig`` for training loop configuration.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
model: The ``nn.Module`` to optimize. Weights are updated in-place.
|
|
203
|
+
polytope_type: Polytope template ('orthoplex', 'simplex', 'cube').
|
|
204
|
+
epsilon: Entropic regularization (float or LinearEpsilon schedule).
|
|
205
|
+
ent_epsilon: Separate OT solver epsilon. If None, uses epsilon.
|
|
206
|
+
scale_cost: Cost scaling strategy ('mean', 'max_cost', float, or None).
|
|
207
|
+
step_radius: Base step radius multiplied by epsilon.
|
|
208
|
+
probe_radius: Base probe radius multiplied by epsilon.
|
|
209
|
+
num_probe: Number of probe points per direction (default 1).
|
|
210
|
+
K=1 is optimal: multi-probe averaging is redundant when entropic
|
|
211
|
+
regularization is active. K=1 gives ~3x speedup over K=3 with no
|
|
212
|
+
accuracy loss.
|
|
213
|
+
adaptive_probes: Enable adaptive probe count (default False). When
|
|
214
|
+
True, stagnant particles (small displacement) reuse the
|
|
215
|
+
previous step's cost row instead of recomputing, saving
|
|
216
|
+
``V * K`` forward passes each.
|
|
217
|
+
adaptive_probes_threshold: Displacement squared norm below which a
|
|
218
|
+
particle is considered stagnant (default ``1e-6``).
|
|
219
|
+
max_iterations: Maximum outer iterations (for momentum warmup schedule).
|
|
220
|
+
sinkhorn_max_iters: Maximum inner Sinkhorn iterations.
|
|
221
|
+
chunk_size: Chunk size for cost evaluation memory control.
|
|
222
|
+
cost_batch_size: Optional mini-batch size for cost matrix evaluation.
|
|
223
|
+
Note: stored for the training loop closure to read; not used
|
|
224
|
+
internally by the optimizer.
|
|
225
|
+
compile: Whether to compile hot-path tensor functions. Defaults to
|
|
226
|
+
False because ablation experiments show no measurable end-to-end
|
|
227
|
+
speedup while incurring JIT warm-up overhead.
|
|
228
|
+
solver: OT/weighting solver strategy. 'softmax' for direct softmax
|
|
229
|
+
weighting (fast, no dual potentials), 'sinkhorn' for entropic OT
|
|
230
|
+
solver (iterative, with warm-started duals). None (default)
|
|
231
|
+
auto-selects: softmax for subspace modes, sinkhorn for full-space.
|
|
232
|
+
ProgressiveEpsilon (auto_epsilon=True) is incompatible with softmax.
|
|
233
|
+
subspace: Optional LowRankSubspace for subspace mode.
|
|
234
|
+
subspace_particle_dim: Particle dimension for subspace mode (default 8).
|
|
235
|
+
In subspace mode, this overrides ``particle_dim`` for the OT
|
|
236
|
+
polytope dimension. Use this parameter (not ``particle_dim``)
|
|
237
|
+
to control polytope geometry in subspace experiments.
|
|
238
|
+
Higher values give more vertices (2*dim for orthoplex) and stronger
|
|
239
|
+
per-step signal. Only used when subspace is not None.
|
|
240
|
+
absorb_every: Periodic absorb interval (default 0 = disabled). When > 0
|
|
241
|
+
and subspace is active, folds current perturbation into base weights
|
|
242
|
+
every N steps, zeroing the subspace vector to explore new regions.
|
|
243
|
+
block_strategy: 'monolithic', 'per_layer', or 'grouped'.
|
|
244
|
+
block_group_size: Number of consecutive entries per block group.
|
|
245
|
+
use_momentum: Enable momentum velocity accumulation.
|
|
246
|
+
momentum_init: Starting momentum coefficient.
|
|
247
|
+
momentum_final: Final momentum coefficient.
|
|
248
|
+
velocity_lr: Learning rate for velocity update.
|
|
249
|
+
use_adaptive_radius: Enable stagnation-based radius adaptation.
|
|
250
|
+
stagnation_threshold: Relative change below which is stagnation.
|
|
251
|
+
stagnation_patience: Stagnation iterations before radius boost.
|
|
252
|
+
radius_increase: Multiplicative factor for radius boost.
|
|
253
|
+
radius_decrease: Multiplicative factor for radius decay.
|
|
254
|
+
radius_min: Minimum allowed radius multiplier.
|
|
255
|
+
radius_max: Maximum allowed radius multiplier.
|
|
256
|
+
use_covariance_adaptation: Enable diagonal CMA-ES covariance learning.
|
|
257
|
+
Requires CMAAdaptiveSubspace. Learns per-dimension scaling of the
|
|
258
|
+
search distribution.
|
|
259
|
+
use_csa: Enable CSA (Cumulative Step-size Adaptation) from CMA-ES.
|
|
260
|
+
Requires CMAAdaptiveSubspace. **Warning**: CSA may be unstable for
|
|
261
|
+
OT-based optimization because OT displacement is ~1-5% of polytope
|
|
262
|
+
size (vs ~100% in standard CMA-ES). Recommend use_adaptive_radius=True
|
|
263
|
+
instead for stable step-size adaptation.
|
|
264
|
+
seed: Optional seed for reproducible random rotations.
|
|
265
|
+
mixed_precision: Enable BF16 model forward passes with FP32 Sinkhorn
|
|
266
|
+
solver internals. Reduces memory usage (~50% for weights) while
|
|
267
|
+
maintaining numerical stability. Requires GPU compute capability
|
|
268
|
+
>= 7.0 (Volta) or CPU. Default False.
|
|
269
|
+
projection_type: Type of projection for AdaptiveSubspace mode.
|
|
270
|
+
'dense' uses QR-orthogonalized dense matrices (default).
|
|
271
|
+
'sparse' uses SparseRandomProjection for memory efficiency.
|
|
272
|
+
'auto' will auto-select based on model size.
|
|
273
|
+
|
|
274
|
+
Hyperparameter quick reference (MNIST as a sanity check):
|
|
275
|
+
|
|
276
|
+
- **Full-space (no subspace).** ``epsilon=LinearEpsilon(1.0 -> 0.1)``,
|
|
277
|
+
``step_radius=0.15``, ``probe_radius=0.12``.
|
|
278
|
+
- **HybridSubspace.** ``rank=4``, ``rotation_interval=0``, decaying
|
|
279
|
+
``epsilon``, ``step_radius=4.5``, ``probe_radius=2.0``.
|
|
280
|
+
- **LinearSubspace.** Same radii as HybridSubspace; decaying ``epsilon``.
|
|
281
|
+
- **AdaptiveSubspace.** Large ``rank`` (e.g. 4096), *fixed* ``epsilon=0.5``,
|
|
282
|
+
``step_radius=10.0``, ``probe_radius=2.0``, ``use_adaptive_radius=True``.
|
|
283
|
+
|
|
284
|
+
Rule of thumb: per-layer subspaces benefit from a decaying epsilon
|
|
285
|
+
schedule; a single global projection prefers a fixed epsilon with
|
|
286
|
+
adaptive radius. Global projections also need a larger ``step_radius``
|
|
287
|
+
than per-layer ones.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
model: nn.Module,
|
|
293
|
+
*,
|
|
294
|
+
polytope_type: str = "orthoplex",
|
|
295
|
+
particle_dim: int = 2,
|
|
296
|
+
epsilon: Union[float, LinearEpsilon] = 0.1,
|
|
297
|
+
ent_epsilon: Optional[Union[float, LinearEpsilon]] = None,
|
|
298
|
+
# Auto-epsilon: ProgOT-inspired feedback-driven epsilon
|
|
299
|
+
auto_epsilon: bool = False,
|
|
300
|
+
auto_epsilon_config: Optional[dict] = None,
|
|
301
|
+
scale_cost: Optional[Union[str, float]] = 1.0,
|
|
302
|
+
step_radius: float = 1.0,
|
|
303
|
+
probe_radius: float = 2.0,
|
|
304
|
+
# Probe-radius jitter (Theorem 4.2 condition (iv)): per-step uniform
|
|
305
|
+
# multiplicative perturbation eta_t ~ U[-eta_max, +eta_max] applied to
|
|
306
|
+
# the resolved probe radius so the joint (rotation, jitter) probe
|
|
307
|
+
# distribution is absolutely continuous on a positive-Lebesgue-measure
|
|
308
|
+
# tube around the (d_p-1)-sphere. The convergence proof formally
|
|
309
|
+
# requires probe_radius_jitter > 0; default 0.0 keeps existing
|
|
310
|
+
# experiments bit-for-bit reproducible. Recommended value: 0.05.
|
|
311
|
+
probe_radius_jitter: float = 0.0,
|
|
312
|
+
num_probe: int = 1,
|
|
313
|
+
# Adaptive probe count: reduce K during exploitation
|
|
314
|
+
adaptive_num_probe: bool = False,
|
|
315
|
+
adaptive_probe_warmup: int = 20,
|
|
316
|
+
# Adaptive probes: reduce evaluations for stagnant particles
|
|
317
|
+
adaptive_probes: bool = False,
|
|
318
|
+
adaptive_probes_threshold: float = 1e-6,
|
|
319
|
+
max_iterations: int = 50,
|
|
320
|
+
sinkhorn_max_iters: int = 2000,
|
|
321
|
+
chunk_size: Optional[int] = None,
|
|
322
|
+
# Micro-batch cost evaluation: subsample training batch for cost matrix
|
|
323
|
+
cost_batch_size: Optional[int] = None,
|
|
324
|
+
# Amortized OT: alternate between full OT steps and cheap momentum steps
|
|
325
|
+
amortize_steps: int = 1,
|
|
326
|
+
amortize_ema: float = 0.7,
|
|
327
|
+
compile: bool = False,
|
|
328
|
+
solver: Optional[str] = None,
|
|
329
|
+
tempered_softmax_tau: float = 1.0,
|
|
330
|
+
subspace: Optional[Union[LowRankSubspace, LinearSubspace, AdaptiveSubspace]] = None,
|
|
331
|
+
subspace_particle_dim: int = 8,
|
|
332
|
+
absorb_every: int = 0,
|
|
333
|
+
rank_schedule: Optional[RankSchedule] = None,
|
|
334
|
+
block_strategy: str = "monolithic",
|
|
335
|
+
block_group_size: int = 2,
|
|
336
|
+
use_momentum: bool = False,
|
|
337
|
+
momentum_init: float = 0.5,
|
|
338
|
+
momentum_final: float = 0.95,
|
|
339
|
+
velocity_lr: float = 1.0,
|
|
340
|
+
use_adaptive_radius: bool = False,
|
|
341
|
+
stagnation_threshold: float = 1e-4,
|
|
342
|
+
stagnation_patience: int = 10,
|
|
343
|
+
radius_increase: float = 1.5,
|
|
344
|
+
radius_decrease: float = 0.9,
|
|
345
|
+
radius_min: float = 0.5,
|
|
346
|
+
radius_max: float = 3.0,
|
|
347
|
+
# Dual potential momentum: extrapolate warm-start duals
|
|
348
|
+
dual_momentum_beta: float = 0.0,
|
|
349
|
+
# Sinkhorn solver improvements: wire through to SinkhornSolver
|
|
350
|
+
anderson_depth: int = 0,
|
|
351
|
+
adaptive_omega: bool = False,
|
|
352
|
+
data_dependent_init: bool = False,
|
|
353
|
+
# Transport-biased rotation: seed first polytope direction from previous OT descent
|
|
354
|
+
biased_rotation: bool = False,
|
|
355
|
+
# Quadratic model: extract FD gradient/Hessian from cost evaluations
|
|
356
|
+
use_quadratic_model: bool = False,
|
|
357
|
+
# Newton refinement: post-OT correction using quadratic model
|
|
358
|
+
newton_refinement: bool = False,
|
|
359
|
+
newton_refinement_alpha: float = 0.3,
|
|
360
|
+
# Trust region: adapt step_radius from predicted vs actual improvement
|
|
361
|
+
trust_region: bool = False,
|
|
362
|
+
# Multi-fidelity screening: dampen low-contrast vertex directions using
|
|
363
|
+
# previous step's cost data to focus OT on informative directions
|
|
364
|
+
multifidelity_screen: bool = False,
|
|
365
|
+
screen_keep_ratio: float = 0.5,
|
|
366
|
+
# CMA-ES configuration
|
|
367
|
+
use_covariance_adaptation: bool = False,
|
|
368
|
+
use_csa: bool = False,
|
|
369
|
+
seed: Optional[int] = None,
|
|
370
|
+
# Mixed precision
|
|
371
|
+
mixed_precision: bool = False,
|
|
372
|
+
# Projection type
|
|
373
|
+
projection_type: str = "dense",
|
|
374
|
+
# Compile vmap in NNCostEvaluator (torch.compile the vectorized forward)
|
|
375
|
+
compile_evaluator: bool = False,
|
|
376
|
+
) -> None:
|
|
377
|
+
# Projection type validation
|
|
378
|
+
if projection_type not in ("dense", "sparse", "auto"):
|
|
379
|
+
raise ValueError(f"Invalid projection_type: {projection_type!r}. Use 'dense', 'sparse', or 'auto'.")
|
|
380
|
+
self._requested_projection_type = projection_type
|
|
381
|
+
self._compile_evaluator = compile_evaluator
|
|
382
|
+
|
|
383
|
+
# Particle dimension validation
|
|
384
|
+
if particle_dim < 2:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"particle_dim must be >= 2, got {particle_dim}. The OT polytope requires at least 2 dimensions."
|
|
387
|
+
)
|
|
388
|
+
if particle_dim > 4 and polytope_type == "cube":
|
|
389
|
+
warnings.warn(
|
|
390
|
+
f"particle_dim={particle_dim} with polytope_type='cube' produces "
|
|
391
|
+
f"2^{particle_dim}={2**particle_dim} vertices (exponential). "
|
|
392
|
+
f"Consider polytope_type='orthoplex' (2*{particle_dim}={2 * particle_dim} vertices) "
|
|
393
|
+
f"or 'simplex' ({particle_dim + 1} vertices) for better scaling.",
|
|
394
|
+
stacklevel=2,
|
|
395
|
+
)
|
|
396
|
+
self._full_space_particle_dim = particle_dim
|
|
397
|
+
|
|
398
|
+
# Warn if particle_dim is set but will be overridden by subspace_particle_dim
|
|
399
|
+
if subspace is not None and particle_dim != 2:
|
|
400
|
+
warnings.warn(
|
|
401
|
+
f"particle_dim={particle_dim} is ignored in subspace mode. "
|
|
402
|
+
f"The OT polytope uses subspace_particle_dim={subspace_particle_dim} instead. "
|
|
403
|
+
f"Pass subspace_particle_dim={particle_dim} to control polytope geometry.",
|
|
404
|
+
stacklevel=2,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Combined subspace + block-wise mode
|
|
408
|
+
# When both subspace and block_strategy are specified, operate in combined mode:
|
|
409
|
+
# global projection compresses full params to subspace coords, then per-block OT
|
|
410
|
+
# decomposes the subspace coordinate optimization.
|
|
411
|
+
self._subspace_blockwise = subspace is not None and block_strategy != "monolithic"
|
|
412
|
+
|
|
413
|
+
# Mixed precision config
|
|
414
|
+
self._mixed_precision = mixed_precision
|
|
415
|
+
self._model_dtype = torch.float32 # default, updated below if mixed_precision enabled
|
|
416
|
+
|
|
417
|
+
# Store model and config
|
|
418
|
+
self.model = model
|
|
419
|
+
self.polytope_type = polytope_type
|
|
420
|
+
self.epsilon = epsilon
|
|
421
|
+
self.ent_epsilon = ent_epsilon
|
|
422
|
+
|
|
423
|
+
# Auto-epsilon: ProgressiveEpsilon with solver feedback
|
|
424
|
+
if auto_epsilon:
|
|
425
|
+
from .epsilon import ProgressiveEpsilon
|
|
426
|
+
|
|
427
|
+
if isinstance(epsilon, LinearEpsilon):
|
|
428
|
+
prog_init = epsilon.init
|
|
429
|
+
prog_target = epsilon.target
|
|
430
|
+
elif isinstance(epsilon, (int, float)):
|
|
431
|
+
prog_init = float(epsilon)
|
|
432
|
+
prog_target = max(0.01, prog_init * 0.1)
|
|
433
|
+
else:
|
|
434
|
+
prog_init = 1.0
|
|
435
|
+
prog_target = 0.01
|
|
436
|
+
config = auto_epsilon_config or {}
|
|
437
|
+
self._progressive_epsilon = ProgressiveEpsilon(
|
|
438
|
+
init=config.get("init", prog_init),
|
|
439
|
+
target=config.get("target", prog_target),
|
|
440
|
+
max_epsilon=config.get("max_epsilon", prog_init * 5.0),
|
|
441
|
+
increase_factor=config.get("increase_factor", 1.2),
|
|
442
|
+
decrease_factor=config.get("decrease_factor", 0.95),
|
|
443
|
+
fast_threshold=config.get("fast_threshold", 0.1),
|
|
444
|
+
slow_threshold=config.get("slow_threshold", 0.5),
|
|
445
|
+
ema_alpha=config.get("ema_alpha", 0.7),
|
|
446
|
+
)
|
|
447
|
+
else:
|
|
448
|
+
self._progressive_epsilon = None
|
|
449
|
+
self.scale_cost = scale_cost
|
|
450
|
+
self.step_radius = step_radius
|
|
451
|
+
self.probe_radius = probe_radius
|
|
452
|
+
if not (0.0 <= probe_radius_jitter < 1.0):
|
|
453
|
+
raise ValueError(
|
|
454
|
+
f"probe_radius_jitter must be in [0, 1), got {probe_radius_jitter}. "
|
|
455
|
+
f"Values >= 1 risk negative effective probe radius."
|
|
456
|
+
)
|
|
457
|
+
self.probe_radius_jitter = probe_radius_jitter
|
|
458
|
+
if num_probe < 1:
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f"num_probe must be >= 1, got {num_probe}. "
|
|
461
|
+
f"At least one probe point per direction is required; "
|
|
462
|
+
f"num_probe=0 yields an empty probe tensor and NaN costs."
|
|
463
|
+
)
|
|
464
|
+
self.num_probe = num_probe
|
|
465
|
+
self.adaptive_num_probe = adaptive_num_probe
|
|
466
|
+
self._adaptive_probe_warmup = adaptive_probe_warmup
|
|
467
|
+
self._loss_decreasing_count = 0
|
|
468
|
+
# OT-step-only costs for adaptive_num_probe check (avoids mixing with momentum costs)
|
|
469
|
+
self._ot_step_costs: collections.deque = collections.deque(maxlen=3)
|
|
470
|
+
self._adaptive_probes = adaptive_probes
|
|
471
|
+
self._adaptive_probes_threshold = adaptive_probes_threshold
|
|
472
|
+
# Previous per-particle displacement squared norms for stagnation detection
|
|
473
|
+
self._prev_displacement_sqnorms: Optional[torch.Tensor] = None
|
|
474
|
+
# Previous cost matrix rows for reuse by stagnant particles
|
|
475
|
+
self._prev_cost_matrix: Optional[torch.Tensor] = None
|
|
476
|
+
# Previous rotation matrices: stagnant particles reuse theirs so a reused
|
|
477
|
+
# cost row still describes the vertices it was evaluated at.
|
|
478
|
+
self._prev_rot_mats: Optional[torch.Tensor] = None
|
|
479
|
+
# Track K_eff and step_r to invalidate _prev_cost_matrix on change
|
|
480
|
+
self._prev_k_eff: Optional[int] = None
|
|
481
|
+
self._prev_step_r: Optional[float] = None
|
|
482
|
+
self.max_iterations = max_iterations
|
|
483
|
+
self.chunk_size = chunk_size
|
|
484
|
+
self.cost_batch_size = cost_batch_size
|
|
485
|
+
self.amortize_steps = max(1, amortize_steps)
|
|
486
|
+
self.amortize_ema = amortize_ema
|
|
487
|
+
|
|
488
|
+
# SNN-like models (LIF / Leaky / Spiking / ALIF cells) collapse
|
|
489
|
+
# from ~93% to 10-47% accuracy when step_radius is on a cosine
|
|
490
|
+
# schedule -- the discrete spike landscape is too chaotic for a
|
|
491
|
+
# shrinking step. Warn loudly when we detect that combination.
|
|
492
|
+
# Heuristic only -- matches substring of module class names.
|
|
493
|
+
if hasattr(step_radius, "at"):
|
|
494
|
+
module_classes = {type(m).__name__ for m in model.modules()}
|
|
495
|
+
snn_markers = ("lif", "leaky", "spik", "spiking", "alif")
|
|
496
|
+
if any(marker in cls.lower() for cls in module_classes for marker in snn_markers):
|
|
497
|
+
warnings.warn(
|
|
498
|
+
"Detected SNN-like module (LIF/Leaky/Spiking) with a "
|
|
499
|
+
"scheduled step_radius (CosineEpsilon or similar). "
|
|
500
|
+
"Per the paper experiments scheduling step_radius on "
|
|
501
|
+
"SNN models collapses accuracy from ~93% to 10-47%. "
|
|
502
|
+
"Pass a flat float for step_radius on SNN tasks.",
|
|
503
|
+
stacklevel=2,
|
|
504
|
+
)
|
|
505
|
+
self._amortize_counter = 0
|
|
506
|
+
self._transport_direction = None
|
|
507
|
+
self._transport_direction_ema = None
|
|
508
|
+
# Transport-biased rotation: store previous OT descent direction
|
|
509
|
+
self.biased_rotation = biased_rotation
|
|
510
|
+
self._prev_descent_direction: Optional[torch.Tensor] = None
|
|
511
|
+
self._prev_descent_direction_finite: bool = False
|
|
512
|
+
# Quadratic model: FD gradient/Hessian extraction from cost evaluations
|
|
513
|
+
self.use_quadratic_model = use_quadratic_model
|
|
514
|
+
self._prev_losses_3d = None # (P, V, K) retained for quadratic model
|
|
515
|
+
# Newton refinement: post-OT correction using quadratic model
|
|
516
|
+
self._newton_refinement = newton_refinement
|
|
517
|
+
self._newton_refinement_alpha = newton_refinement_alpha
|
|
518
|
+
if newton_refinement and not use_quadratic_model:
|
|
519
|
+
self.use_quadratic_model = True
|
|
520
|
+
logger.info(
|
|
521
|
+
"newton_refinement=True auto-enables use_quadratic_model=True "
|
|
522
|
+
"(needed to retain probe losses for Newton correction)"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
self._newton_direction = None # (P, pdim) Newton step in original space
|
|
526
|
+
# Trust region: adapt step_radius via multiplier based on quadratic model
|
|
527
|
+
self.trust_region = trust_region
|
|
528
|
+
self._trust_region_multiplier = 1.0 # Multiplier on step_radius, range [0.1, 3.0]
|
|
529
|
+
self._prev_predicted_improvement = None
|
|
530
|
+
self._prev_pre_step_loss = None # Per-particle min cost proxy
|
|
531
|
+
# Trust region needs the finite-difference model to form predicted-vs-actual
|
|
532
|
+
# ratios; auto-enable it (mirrors newton_refinement) and warn on the two
|
|
533
|
+
# prerequisites that would otherwise leave the multiplier frozen at 1.0.
|
|
534
|
+
if trust_region and not self.use_quadratic_model:
|
|
535
|
+
self.use_quadratic_model = True
|
|
536
|
+
logger.info(
|
|
537
|
+
"trust_region=True auto-enables use_quadratic_model=True "
|
|
538
|
+
"(needed for the predicted-vs-actual ratio test)"
|
|
539
|
+
)
|
|
540
|
+
if trust_region and num_probe < 2:
|
|
541
|
+
warnings.warn(
|
|
542
|
+
"trust_region needs num_probe>=2 to build the finite-difference model; "
|
|
543
|
+
"it stays inactive until num_probe>=2.",
|
|
544
|
+
stacklevel=2,
|
|
545
|
+
)
|
|
546
|
+
if trust_region and block_strategy != "monolithic":
|
|
547
|
+
warnings.warn(
|
|
548
|
+
f"trust_region is only applied with block_strategy='monolithic'; "
|
|
549
|
+
f"ignored for block_strategy='{block_strategy}'.",
|
|
550
|
+
stacklevel=2,
|
|
551
|
+
)
|
|
552
|
+
# Multi-fidelity vertex screening: dampen low-contrast directions
|
|
553
|
+
self.multifidelity_screen = multifidelity_screen
|
|
554
|
+
self.screen_keep_ratio = screen_keep_ratio
|
|
555
|
+
self.subspace = subspace
|
|
556
|
+
self._subspace_particle_dim = subspace_particle_dim
|
|
557
|
+
self.absorb_every = absorb_every
|
|
558
|
+
self._rank_schedule = rank_schedule
|
|
559
|
+
# Validate: rank_schedule requires a subspace
|
|
560
|
+
if rank_schedule is not None and subspace is None:
|
|
561
|
+
raise ValueError("rank_schedule requires a subspace")
|
|
562
|
+
# rank_schedule transitions only run in the monolithic step; disable it
|
|
563
|
+
# elsewhere so it is a clear no-op instead of a silent one (or a crash).
|
|
564
|
+
if rank_schedule is not None and block_strategy != "monolithic":
|
|
565
|
+
warnings.warn(
|
|
566
|
+
f"rank_schedule is only applied with block_strategy='monolithic'; "
|
|
567
|
+
f"ignored for block_strategy='{block_strategy}'.",
|
|
568
|
+
stacklevel=2,
|
|
569
|
+
)
|
|
570
|
+
self._rank_schedule = None
|
|
571
|
+
self.block_strategy = block_strategy
|
|
572
|
+
self.block_group_size = block_group_size
|
|
573
|
+
|
|
574
|
+
# Warn if quadratic model used with non-monolithic block strategy
|
|
575
|
+
if use_quadratic_model and block_strategy != "monolithic":
|
|
576
|
+
warnings.warn(
|
|
577
|
+
f"use_quadratic_model=True is not supported with "
|
|
578
|
+
f"block_strategy='{block_strategy}'. Quadratic model will be "
|
|
579
|
+
f"silently disabled for block-wise steps.",
|
|
580
|
+
stacklevel=2,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Momentum config
|
|
584
|
+
self.use_momentum = use_momentum
|
|
585
|
+
self.momentum_init = momentum_init
|
|
586
|
+
self.momentum_final = momentum_final
|
|
587
|
+
self.velocity_lr = velocity_lr
|
|
588
|
+
|
|
589
|
+
# Adaptive radius config
|
|
590
|
+
self.use_adaptive_radius = use_adaptive_radius
|
|
591
|
+
self.stagnation_threshold = stagnation_threshold
|
|
592
|
+
self.stagnation_patience = stagnation_patience
|
|
593
|
+
self.radius_increase = radius_increase
|
|
594
|
+
self.radius_decrease = radius_decrease
|
|
595
|
+
self.radius_min = radius_min
|
|
596
|
+
self.radius_max = radius_max
|
|
597
|
+
|
|
598
|
+
# Dual momentum config
|
|
599
|
+
self._dual_momentum_beta = dual_momentum_beta
|
|
600
|
+
|
|
601
|
+
# CMA-ES config
|
|
602
|
+
# Detect CMA subspace mode early for validation
|
|
603
|
+
self._cma_subspace = isinstance(subspace, CMAAdaptiveSubspace)
|
|
604
|
+
|
|
605
|
+
# Validate: CMA features require CMAAdaptiveSubspace
|
|
606
|
+
if (use_covariance_adaptation or use_csa) and not self._cma_subspace:
|
|
607
|
+
warnings.warn(
|
|
608
|
+
"use_covariance_adaptation or use_csa requires CMAAdaptiveSubspace. CMA features will be disabled."
|
|
609
|
+
)
|
|
610
|
+
use_covariance_adaptation = False
|
|
611
|
+
use_csa = False
|
|
612
|
+
|
|
613
|
+
# Validate: CMA covariance scaling and the CMA state update are wired
|
|
614
|
+
# only into the monolithic step; blockwise samples through per-block
|
|
615
|
+
# projections, so the paths/covariance never adapt there.
|
|
616
|
+
if (use_covariance_adaptation or use_csa) and self.block_strategy != "monolithic":
|
|
617
|
+
warnings.warn(
|
|
618
|
+
"use_covariance_adaptation/use_csa are only supported with block_strategy='monolithic'; "
|
|
619
|
+
f"disabled for block_strategy='{self.block_strategy}'."
|
|
620
|
+
)
|
|
621
|
+
use_covariance_adaptation = False
|
|
622
|
+
use_csa = False
|
|
623
|
+
|
|
624
|
+
# Validate: CSA replaces heuristic adaptive radius
|
|
625
|
+
if use_csa and use_adaptive_radius:
|
|
626
|
+
warnings.warn(
|
|
627
|
+
"Both use_csa and use_adaptive_radius enabled. CSA will be used, heuristic radius adaptation disabled."
|
|
628
|
+
)
|
|
629
|
+
use_adaptive_radius = False
|
|
630
|
+
self.use_adaptive_radius = False
|
|
631
|
+
|
|
632
|
+
self.use_covariance_adaptation = use_covariance_adaptation
|
|
633
|
+
self.use_csa = use_csa
|
|
634
|
+
# Coord-to-param projection for the current step, covariance-scaled for
|
|
635
|
+
# CMA. Cached so a step's probes and its sync share one metric.
|
|
636
|
+
self._sampling_projection = None
|
|
637
|
+
|
|
638
|
+
# Create layout from model
|
|
639
|
+
# Thread particle_dim for full-space mode; subspace mode ignores this
|
|
640
|
+
# (subspace uses subspace_particle_dim instead)
|
|
641
|
+
self.layout = ParamLayout.from_module(model, particle_dim=self._full_space_particle_dim)
|
|
642
|
+
|
|
643
|
+
# Detect model device for tensor creation
|
|
644
|
+
try:
|
|
645
|
+
model_device = next(model.parameters()).device
|
|
646
|
+
except StopIteration:
|
|
647
|
+
raise ValueError("Model has no trainable parameters. PolyStepOptimizer requires at least one parameter.")
|
|
648
|
+
|
|
649
|
+
# Auto-selection of projection type based on model size
|
|
650
|
+
num_params = sum(p.numel() for p in model.parameters())
|
|
651
|
+
self._actual_projection_type = _select_projection_type(
|
|
652
|
+
num_params, model_device, self._requested_projection_type
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# Log auto-selection choice
|
|
656
|
+
if self._requested_projection_type == "auto":
|
|
657
|
+
logger.info(
|
|
658
|
+
f"Auto-selected {self._actual_projection_type} projection for "
|
|
659
|
+
f"{num_params / 1e6:.1f}M params on {model_device}"
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Fallback for tiny models: sparse has overhead that isn't worth it
|
|
663
|
+
if self._actual_projection_type == "sparse" and num_params < _MIN_PARAMS_FOR_SPARSE:
|
|
664
|
+
logger.info(
|
|
665
|
+
f"Model has {num_params:,} params (<{_MIN_PARAMS_FOR_SPARSE:,}). "
|
|
666
|
+
f"Using dense projection instead of sparse."
|
|
667
|
+
)
|
|
668
|
+
self._actual_projection_type = "dense"
|
|
669
|
+
|
|
670
|
+
# Mixed precision: cast model to BF16 for memory savings
|
|
671
|
+
if mixed_precision:
|
|
672
|
+
if not self._bf16_supported():
|
|
673
|
+
warnings.warn(
|
|
674
|
+
"BF16 not supported on this device. Falling back to FP32. "
|
|
675
|
+
"For GPU: requires compute capability >= 7.0 (Volta+)."
|
|
676
|
+
)
|
|
677
|
+
else:
|
|
678
|
+
model.bfloat16()
|
|
679
|
+
self._model_dtype = torch.bfloat16
|
|
680
|
+
|
|
681
|
+
# ------------------------------------------------------------------
|
|
682
|
+
# Multi-particle architecture:
|
|
683
|
+
# Parameters are reshaped to (num_particles, particle_dim) where
|
|
684
|
+
# particle_dim is the layout's column count (typically 2). The OT
|
|
685
|
+
# polytope operates in particle_dim space, giving a small number of
|
|
686
|
+
# vertices (e.g., 4 for orthoplex in 2D, 3 for simplex).
|
|
687
|
+
# ------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
# Detect adaptive subspace mode
|
|
690
|
+
self._adaptive = isinstance(subspace, AdaptiveSubspace)
|
|
691
|
+
|
|
692
|
+
# Detect hybrid subspace mode
|
|
693
|
+
self._hybrid = isinstance(subspace, HybridSubspace)
|
|
694
|
+
self._hybrid_subspace = subspace if self._hybrid else None
|
|
695
|
+
|
|
696
|
+
if subspace is not None:
|
|
697
|
+
# Subspace mode: subspace coords reshaped to multi-particle format.
|
|
698
|
+
# Use the user-specified particle_dim if it was explicitly set;
|
|
699
|
+
# otherwise fall back to subspace_particle_dim (default 8) for a
|
|
700
|
+
# stronger per-step OT signal (orthoplex in 8D -> 16 vertices).
|
|
701
|
+
self._base_params = {k: v.clone() for k, v in model.state_dict().items()}
|
|
702
|
+
sub_dim = subspace.subspace_dim
|
|
703
|
+
pdim = subspace_particle_dim
|
|
704
|
+
# Pad subspace_dim to be divisible by particle_dim
|
|
705
|
+
padded_sub = sub_dim + (-sub_dim % pdim)
|
|
706
|
+
num_sub_particles = padded_sub // pdim
|
|
707
|
+
self._sub_padded = padded_sub # for unpadding later
|
|
708
|
+
# Start at base params => delta=0 => subspace coords = zeros
|
|
709
|
+
# Use model_dtype for mixed precision compatibility
|
|
710
|
+
x_dtype = self._model_dtype if self._mixed_precision else self.layout.dominant_dtype
|
|
711
|
+
X_init = torch.zeros(
|
|
712
|
+
num_sub_particles,
|
|
713
|
+
pdim,
|
|
714
|
+
dtype=x_dtype,
|
|
715
|
+
device=model_device,
|
|
716
|
+
)
|
|
717
|
+
particle_dim = pdim
|
|
718
|
+
else:
|
|
719
|
+
# Full parameter mode: (num_particles, particle_dim) from layout
|
|
720
|
+
flat_2d = self.layout.flatten(model) # (rows, particle_dim)
|
|
721
|
+
X_init = flat_2d.to(model_device)
|
|
722
|
+
particle_dim = self.layout.particle_dim
|
|
723
|
+
|
|
724
|
+
self._particle_dim = particle_dim
|
|
725
|
+
device = X_init.device
|
|
726
|
+
|
|
727
|
+
# Polytope template in particle_dim space (NOT full parameter space).
|
|
728
|
+
# This gives a small number of vertices (e.g., 4 for orthoplex in 2D).
|
|
729
|
+
# Skip when using block-wise mode (per-block polytopes used instead).
|
|
730
|
+
if block_strategy == "monolithic":
|
|
731
|
+
self._polytope_vertices = POLYTOPE_MAP[polytope_type](
|
|
732
|
+
particle_dim,
|
|
733
|
+
device=device,
|
|
734
|
+
dtype=X_init.dtype,
|
|
735
|
+
radius=1.0,
|
|
736
|
+
)
|
|
737
|
+
else:
|
|
738
|
+
self._polytope_vertices = None # per-block polytopes used instead
|
|
739
|
+
|
|
740
|
+
# Probe linspace (exclude endpoints)
|
|
741
|
+
self._probes = torch.linspace(0, 1, num_probe + 2)[1 : num_probe + 1]
|
|
742
|
+
|
|
743
|
+
if solver is None:
|
|
744
|
+
solver = "softmax" if subspace is not None else "sinkhorn"
|
|
745
|
+
|
|
746
|
+
if solver == "softmax":
|
|
747
|
+
self.solver = SoftmaxSolver(compile=compile)
|
|
748
|
+
elif solver == "sinkhorn":
|
|
749
|
+
self.solver = SinkhornSolver(
|
|
750
|
+
max_iterations=sinkhorn_max_iters,
|
|
751
|
+
compile=compile,
|
|
752
|
+
anderson_depth=anderson_depth,
|
|
753
|
+
adaptive_omega=adaptive_omega,
|
|
754
|
+
data_dependent_init=data_dependent_init,
|
|
755
|
+
)
|
|
756
|
+
elif solver == "min_cost_greedy":
|
|
757
|
+
self.solver = MinCostGreedySolver(compile=compile)
|
|
758
|
+
elif solver == "top_k_mean":
|
|
759
|
+
self.solver = TopKMeanSolver(compile=compile)
|
|
760
|
+
elif solver == "tempered_softmax":
|
|
761
|
+
self.solver = TemperedSoftmaxSolver(
|
|
762
|
+
tau=tempered_softmax_tau,
|
|
763
|
+
compile=compile,
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"Unknown solver: {solver!r}. Expected 'softmax', 'sinkhorn', "
|
|
768
|
+
f"'min_cost_greedy', 'top_k_mean', 'tempered_softmax', or None."
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
_non_iterative_solvers = ("softmax", "min_cost_greedy", "top_k_mean", "tempered_softmax")
|
|
772
|
+
if self._progressive_epsilon is not None and solver in _non_iterative_solvers:
|
|
773
|
+
raise ValueError(
|
|
774
|
+
"ProgressiveEpsilon requires Sinkhorn convergence feedback. "
|
|
775
|
+
"Use LinearEpsilon or CosineEpsilon with non-iterative solvers."
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# Fused softmax fast path flag. Cost scaling for the fused path is
|
|
779
|
+
# applied via scale_cost_matrix(...) in the step functions (honoring
|
|
780
|
+
# every scale_cost mode), so the compiled kernel does no scaling.
|
|
781
|
+
self._use_fused_softmax = isinstance(self.solver, SoftmaxSolver)
|
|
782
|
+
|
|
783
|
+
# Compiled functions
|
|
784
|
+
self._compiled = CompiledFunctions(compile=compile and torch.cuda.is_available())
|
|
785
|
+
|
|
786
|
+
# Random generator (created early so AdaptiveSubspace init can use it)
|
|
787
|
+
self._seed = seed
|
|
788
|
+
self._generator: Optional[torch.Generator] = None
|
|
789
|
+
if seed is not None:
|
|
790
|
+
self._generator = create_generator(seed, device)
|
|
791
|
+
|
|
792
|
+
# Initialize solver state
|
|
793
|
+
num_points = X_init.shape[0]
|
|
794
|
+
a = torch.ones(num_points, device=device, dtype=X_init.dtype) / num_points
|
|
795
|
+
self._state = SolverState(X=X_init.clone(), a=a)
|
|
796
|
+
|
|
797
|
+
if subspace is not None:
|
|
798
|
+
self._state.base_params = self._base_params
|
|
799
|
+
self._state.subspace = subspace
|
|
800
|
+
|
|
801
|
+
# Initialize subspace projections and displacement history
|
|
802
|
+
self._init_subspace(subspace, seed, model_device)
|
|
803
|
+
|
|
804
|
+
# Initialize CMA-ES state (evolution paths, covariance, step-size)
|
|
805
|
+
self._init_cma(subspace, seed, model_device)
|
|
806
|
+
|
|
807
|
+
if use_momentum:
|
|
808
|
+
self._state.velocity = torch.zeros_like(X_init)
|
|
809
|
+
|
|
810
|
+
# Initialize block-wise decomposition (polytopes, duals)
|
|
811
|
+
self._init_blocks(
|
|
812
|
+
subspace,
|
|
813
|
+
subspace_particle_dim,
|
|
814
|
+
block_strategy,
|
|
815
|
+
block_group_size,
|
|
816
|
+
polytope_type,
|
|
817
|
+
X_init,
|
|
818
|
+
device,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
# ------------------------------------------------------------------
|
|
822
|
+
# Init helpers (split from __init__ for readability)
|
|
823
|
+
# ------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
def _init_subspace(self, subspace, seed, model_device) -> None:
|
|
826
|
+
"""Initialize subspace projections and displacement history."""
|
|
827
|
+
if subspace is None:
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
# AdaptiveSubspace: initialize projection and displacement history
|
|
831
|
+
if self._adaptive:
|
|
832
|
+
if self._actual_projection_type == "sparse":
|
|
833
|
+
from .projection import SparseRandomProjection
|
|
834
|
+
|
|
835
|
+
self._state.projection = SparseRandomProjection(
|
|
836
|
+
full_dim=subspace.full_dim,
|
|
837
|
+
subspace_dim=subspace.subspace_dim,
|
|
838
|
+
seed=seed if seed is not None else 0,
|
|
839
|
+
)
|
|
840
|
+
else:
|
|
841
|
+
self._state.projection = subspace.init_projection(
|
|
842
|
+
generator=self._generator,
|
|
843
|
+
device=model_device,
|
|
844
|
+
dtype=self._model_dtype,
|
|
845
|
+
)
|
|
846
|
+
self._state.displacement_history = torch.zeros(
|
|
847
|
+
subspace.displacement_history_size,
|
|
848
|
+
subspace.subspace_dim,
|
|
849
|
+
device=model_device,
|
|
850
|
+
dtype=self._model_dtype,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# HybridSubspace: initialize per-layer projections and displacement history
|
|
854
|
+
if self._hybrid:
|
|
855
|
+
self._state.hybrid_projections = subspace.init_projections(
|
|
856
|
+
model_device,
|
|
857
|
+
self._model_dtype,
|
|
858
|
+
)
|
|
859
|
+
if hasattr(subspace, "build_fused_projection"):
|
|
860
|
+
subspace.build_fused_projection(self._state.hybrid_projections)
|
|
861
|
+
self._state.displacement_history = torch.zeros(
|
|
862
|
+
subspace.displacement_history_size,
|
|
863
|
+
subspace.subspace_dim,
|
|
864
|
+
device=model_device,
|
|
865
|
+
dtype=self._model_dtype,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def _init_cma(self, subspace, seed, model_device) -> None:
|
|
869
|
+
"""Initialize CMA-ES state: projection, displacement history, evolution paths."""
|
|
870
|
+
self._cma_params = None
|
|
871
|
+
if not self._cma_subspace:
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
# CMAAdaptiveSubspace wraps AdaptiveSubspace, so it also needs projection init
|
|
875
|
+
if self._actual_projection_type == "sparse":
|
|
876
|
+
from .projection import SparseRandomProjection
|
|
877
|
+
|
|
878
|
+
self._state.projection = SparseRandomProjection(
|
|
879
|
+
full_dim=subspace.full_dim,
|
|
880
|
+
subspace_dim=subspace.subspace_dim,
|
|
881
|
+
seed=seed if seed is not None else 0,
|
|
882
|
+
)
|
|
883
|
+
else:
|
|
884
|
+
self._state.projection = subspace.init_projection(
|
|
885
|
+
generator=self._generator,
|
|
886
|
+
device=model_device,
|
|
887
|
+
dtype=self._model_dtype,
|
|
888
|
+
)
|
|
889
|
+
self._state.displacement_history = torch.zeros(
|
|
890
|
+
subspace.base.displacement_history_size,
|
|
891
|
+
subspace.subspace_dim,
|
|
892
|
+
device=model_device,
|
|
893
|
+
dtype=self._model_dtype,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
if self.use_covariance_adaptation or self.use_csa:
|
|
897
|
+
cma_state = subspace.init_cma_state(
|
|
898
|
+
device=model_device,
|
|
899
|
+
dtype=self._model_dtype,
|
|
900
|
+
)
|
|
901
|
+
self._state.p_c = cma_state["p_c"]
|
|
902
|
+
self._state.p_sigma = cma_state["p_sigma"]
|
|
903
|
+
self._state.C_diag = cma_state["C_diag"]
|
|
904
|
+
self._state.sigma = 1.0
|
|
905
|
+
self._state.generation = 0
|
|
906
|
+
self._state.use_csa = self.use_csa
|
|
907
|
+
self._cma_params = {
|
|
908
|
+
"c_c": subspace.c_c,
|
|
909
|
+
"c_sigma": subspace.c_sigma,
|
|
910
|
+
"c_1": subspace.c_1,
|
|
911
|
+
"c_mu": subspace.c_mu,
|
|
912
|
+
"d_sigma": subspace.d_sigma,
|
|
913
|
+
"expected_norm": subspace.expected_norm,
|
|
914
|
+
"mu_eff": subspace.mu_eff,
|
|
915
|
+
"cov_min": subspace.cov_min,
|
|
916
|
+
"cov_max": subspace.cov_max,
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
def _init_blocks(
|
|
920
|
+
self,
|
|
921
|
+
subspace,
|
|
922
|
+
subspace_particle_dim,
|
|
923
|
+
block_strategy,
|
|
924
|
+
block_group_size,
|
|
925
|
+
polytope_type,
|
|
926
|
+
X_init,
|
|
927
|
+
device,
|
|
928
|
+
) -> None:
|
|
929
|
+
"""Initialize block-wise decomposition: blocks, polytopes, duals."""
|
|
930
|
+
self._blocks: Optional[List[BlockConfig]] = None
|
|
931
|
+
self._block_polytopes: Optional[List[torch.Tensor]] = None
|
|
932
|
+
self._subspace_blocks: Optional[List[BlockConfig]] = None
|
|
933
|
+
self._subspace_block_polytopes: Optional[List[torch.Tensor]] = None
|
|
934
|
+
|
|
935
|
+
if self._subspace_blockwise:
|
|
936
|
+
sub_dim = subspace.subspace_dim
|
|
937
|
+
num_blocks = min(len(self.layout.entries), 8)
|
|
938
|
+
num_blocks = max(2, num_blocks)
|
|
939
|
+
|
|
940
|
+
self._subspace_blocks = create_subspace_blocks(
|
|
941
|
+
subspace_dim=sub_dim,
|
|
942
|
+
num_blocks=num_blocks,
|
|
943
|
+
subspace_particle_dim=subspace_particle_dim,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
self._subspace_block_polytopes = []
|
|
947
|
+
for block in self._subspace_blocks:
|
|
948
|
+
self._subspace_block_polytopes.append(
|
|
949
|
+
POLYTOPE_MAP[polytope_type](
|
|
950
|
+
block.particle_dim,
|
|
951
|
+
device=device,
|
|
952
|
+
dtype=X_init.dtype,
|
|
953
|
+
radius=1.0,
|
|
954
|
+
)
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
self._state.block_duals = [(None, None) for _ in self._subspace_blocks]
|
|
958
|
+
|
|
959
|
+
elif block_strategy != "monolithic":
|
|
960
|
+
if block_strategy == "per_layer":
|
|
961
|
+
self._blocks = create_per_layer_blocks(
|
|
962
|
+
self.layout,
|
|
963
|
+
particle_dim=self.layout.particle_dim,
|
|
964
|
+
)
|
|
965
|
+
elif block_strategy == "grouped":
|
|
966
|
+
self._blocks = create_grouped_blocks(
|
|
967
|
+
self.layout,
|
|
968
|
+
group_size=block_group_size,
|
|
969
|
+
particle_dim=self.layout.particle_dim,
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
raise ValueError(
|
|
973
|
+
f"Unknown block_strategy: {block_strategy!r}. Use 'monolithic', 'per_layer', or 'grouped'."
|
|
974
|
+
)
|
|
975
|
+
self._block_polytopes = []
|
|
976
|
+
for block in self._blocks:
|
|
977
|
+
self._block_polytopes.append(
|
|
978
|
+
POLYTOPE_MAP[polytope_type](
|
|
979
|
+
block.particle_dim,
|
|
980
|
+
device=device,
|
|
981
|
+
dtype=X_init.dtype,
|
|
982
|
+
radius=1.0,
|
|
983
|
+
)
|
|
984
|
+
)
|
|
985
|
+
self._state.block_duals = [(None, None) for _ in self._blocks]
|
|
986
|
+
|
|
987
|
+
# ------------------------------------------------------------------
|
|
988
|
+
# Properties
|
|
989
|
+
# ------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
@property
|
|
992
|
+
def state(self) -> SolverState:
|
|
993
|
+
"""Read-only access to the current solver state."""
|
|
994
|
+
return self._state
|
|
995
|
+
|
|
996
|
+
@property
|
|
997
|
+
def mixed_precision(self) -> bool:
|
|
998
|
+
"""Whether mixed precision (BF16) is enabled."""
|
|
999
|
+
return self._mixed_precision
|
|
1000
|
+
|
|
1001
|
+
@property
|
|
1002
|
+
def model_dtype(self) -> torch.dtype:
|
|
1003
|
+
"""Current dtype of the model parameters."""
|
|
1004
|
+
return self._model_dtype
|
|
1005
|
+
|
|
1006
|
+
@property
|
|
1007
|
+
def projection_type(self) -> str:
|
|
1008
|
+
"""Actual projection type being used ('dense' or 'sparse').
|
|
1009
|
+
|
|
1010
|
+
Note: When 'auto' is requested, this returns the resolved type based
|
|
1011
|
+
on model size (sparse for >1M params on CPU, >2M on GPU).
|
|
1012
|
+
"""
|
|
1013
|
+
return self._actual_projection_type
|
|
1014
|
+
|
|
1015
|
+
# ------------------------------------------------------------------
|
|
1016
|
+
# Epsilon resolution
|
|
1017
|
+
# ------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
def _get_epsilon(self, iteration: int) -> float:
|
|
1020
|
+
"""Resolve epsilon at current iteration."""
|
|
1021
|
+
if self._progressive_epsilon is not None:
|
|
1022
|
+
return self._progressive_epsilon.at(iteration)
|
|
1023
|
+
if hasattr(self.epsilon, "at"):
|
|
1024
|
+
return self.epsilon.at(iteration)
|
|
1025
|
+
return self.epsilon
|
|
1026
|
+
|
|
1027
|
+
def _get_step_radius(self, iteration: int) -> float:
|
|
1028
|
+
"""Resolve step_radius at current iteration (supports schedule objects)."""
|
|
1029
|
+
if hasattr(self.step_radius, "at"):
|
|
1030
|
+
return self.step_radius.at(iteration)
|
|
1031
|
+
return self.step_radius
|
|
1032
|
+
|
|
1033
|
+
def _get_probe_radius(self, iteration: int) -> float:
|
|
1034
|
+
"""Resolve probe_radius at current iteration (supports schedule objects)."""
|
|
1035
|
+
if hasattr(self.probe_radius, "at"):
|
|
1036
|
+
return self.probe_radius.at(iteration)
|
|
1037
|
+
return self.probe_radius
|
|
1038
|
+
|
|
1039
|
+
def _apply_probe_radius_jitter(self, probe_r: float) -> float:
|
|
1040
|
+
"""Apply per-step uniform multiplicative jitter to the probe radius.
|
|
1041
|
+
|
|
1042
|
+
Samples ``eta ~ Uniform[-eta_max, +eta_max]`` and returns
|
|
1043
|
+
``probe_r * (1 + eta)``. When ``probe_radius_jitter == 0`` (default)
|
|
1044
|
+
this is a no-op and consumes no random state.
|
|
1045
|
+
"""
|
|
1046
|
+
eta_max = float(self.probe_radius_jitter)
|
|
1047
|
+
if eta_max <= 0.0:
|
|
1048
|
+
return probe_r
|
|
1049
|
+
if self._generator is not None:
|
|
1050
|
+
device = self._generator.device
|
|
1051
|
+
eta = torch.empty((1,), device=device).uniform_(-eta_max, eta_max, generator=self._generator).item()
|
|
1052
|
+
else:
|
|
1053
|
+
eta = float(torch.empty((1,)).uniform_(-eta_max, eta_max).item())
|
|
1054
|
+
return float(probe_r) * (1.0 + eta)
|
|
1055
|
+
|
|
1056
|
+
def _get_ent_epsilon(self, iteration: int) -> Optional[float]:
|
|
1057
|
+
"""Resolve ent_epsilon at current iteration."""
|
|
1058
|
+
if self.ent_epsilon is None:
|
|
1059
|
+
return None
|
|
1060
|
+
if hasattr(self.ent_epsilon, "at"):
|
|
1061
|
+
return self.ent_epsilon.at(iteration)
|
|
1062
|
+
return self.ent_epsilon
|
|
1063
|
+
|
|
1064
|
+
def _bf16_supported(self) -> bool:
|
|
1065
|
+
"""Check if BF16 is supported on the model's device.
|
|
1066
|
+
|
|
1067
|
+
Returns:
|
|
1068
|
+
True if BF16 is supported and should be used.
|
|
1069
|
+
"""
|
|
1070
|
+
try:
|
|
1071
|
+
device = next(self.model.parameters()).device
|
|
1072
|
+
except StopIteration:
|
|
1073
|
+
return False
|
|
1074
|
+
if device.type == "cuda":
|
|
1075
|
+
if torch.cuda.is_available():
|
|
1076
|
+
cap = torch.cuda.get_device_capability(device)
|
|
1077
|
+
return cap[0] >= 7 # Volta (7.0) and newer
|
|
1078
|
+
return False
|
|
1079
|
+
elif device.type == "cpu":
|
|
1080
|
+
return True # CPU BF16 works, may be slower without AMX
|
|
1081
|
+
else:
|
|
1082
|
+
return True # MPS, XLA, etc. - let PyTorch error if unsupported
|
|
1083
|
+
|
|
1084
|
+
# ------------------------------------------------------------------
|
|
1085
|
+
# Adaptive probes
|
|
1086
|
+
# ------------------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
def _get_stagnant_mask(self, num_particles: int) -> Optional[torch.Tensor]:
|
|
1089
|
+
"""Return boolean mask of stagnant particles based on displacement history.
|
|
1090
|
+
|
|
1091
|
+
Returns:
|
|
1092
|
+
Tensor of shape (num_particles,) with True for stagnant particles,
|
|
1093
|
+
or None if adaptive_probes is disabled or no displacement history yet.
|
|
1094
|
+
"""
|
|
1095
|
+
if not self._adaptive_probes or self._prev_displacement_sqnorms is None:
|
|
1096
|
+
return None
|
|
1097
|
+
|
|
1098
|
+
stagnant = self._prev_displacement_sqnorms < self._adaptive_probes_threshold
|
|
1099
|
+
# Ensure mask length matches current particle count (may differ on first step)
|
|
1100
|
+
if stagnant.shape[0] != num_particles:
|
|
1101
|
+
return None
|
|
1102
|
+
return stagnant
|
|
1103
|
+
|
|
1104
|
+
def _invalidate_reuse_cache(self) -> None:
|
|
1105
|
+
"""Drop the cached cost rows, rotations, and probe state used by
|
|
1106
|
+
adaptive-probe reuse. Call whenever the cost geometry changes (subspace
|
|
1107
|
+
rotation or absorb, rank transition), since a reused row would then
|
|
1108
|
+
describe stale directions.
|
|
1109
|
+
"""
|
|
1110
|
+
self._prev_cost_matrix = None
|
|
1111
|
+
self._prev_rot_mats = None
|
|
1112
|
+
self._prev_losses_3d = None
|
|
1113
|
+
self._prev_displacement_sqnorms = None
|
|
1114
|
+
self._prev_k_eff = None
|
|
1115
|
+
self._prev_step_r = None
|
|
1116
|
+
|
|
1117
|
+
# ------------------------------------------------------------------
|
|
1118
|
+
# Fused inplace evaluation (EGGROLL-inspired)
|
|
1119
|
+
# ------------------------------------------------------------------
|
|
1120
|
+
|
|
1121
|
+
def register_evaluator(
|
|
1122
|
+
self,
|
|
1123
|
+
evaluator: "NNCostEvaluator",
|
|
1124
|
+
inputs: torch.Tensor,
|
|
1125
|
+
targets: torch.Tensor = None,
|
|
1126
|
+
) -> None:
|
|
1127
|
+
"""Register evaluator + data for fused inplace evaluation.
|
|
1128
|
+
|
|
1129
|
+
When registered, the optimizer's chunk loop can bypass
|
|
1130
|
+
``reconstruct_batch`` + ``closure()`` and instead call
|
|
1131
|
+
``evaluator.evaluate_subspace_inplace()`` directly, which
|
|
1132
|
+
reconstructs weights one-at-a-time via in-place swap.
|
|
1133
|
+
|
|
1134
|
+
This reduces memory from O(N x model_params + N x activation) to
|
|
1135
|
+
O(model_params + 1 x activation), enabling training of larger models.
|
|
1136
|
+
|
|
1137
|
+
Call this before each ``step()`` with the current mini-batch data.
|
|
1138
|
+
The fused path is only used when the evaluator has ``_use_inplace=True``
|
|
1139
|
+
(auto-detected for GPU models >50K params).
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
evaluator: NNCostEvaluator instance.
|
|
1143
|
+
inputs: Current mini-batch inputs.
|
|
1144
|
+
targets: Current mini-batch targets (optional).
|
|
1145
|
+
"""
|
|
1146
|
+
self._cost_evaluator = evaluator
|
|
1147
|
+
self._fused_inputs = inputs
|
|
1148
|
+
self._fused_targets = targets
|
|
1149
|
+
|
|
1150
|
+
# ------------------------------------------------------------------
|
|
1151
|
+
# Step: entry point
|
|
1152
|
+
# ------------------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
@torch.inference_mode()
|
|
1155
|
+
def step(self, closure: Callable) -> float:
|
|
1156
|
+
"""Run one optimization step.
|
|
1157
|
+
|
|
1158
|
+
Samples polytope vertices around current particles, calls the user
|
|
1159
|
+
closure to evaluate costs at probe points, solves entropic OT, and
|
|
1160
|
+
updates particles via barycentric projection. Optionally applies
|
|
1161
|
+
momentum and adaptive radius.
|
|
1162
|
+
|
|
1163
|
+
The closure is called once per chunk of probe positions (possibly
|
|
1164
|
+
multiple times if chunking is enabled). It should return a scalar
|
|
1165
|
+
loss per candidate parameter configuration.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
closure: ``closure(batched_params) -> losses`` where
|
|
1169
|
+
``batched_params`` is ``{key: (N, *shape)}`` and ``losses``
|
|
1170
|
+
is a 1D tensor of shape ``(N,)``.
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
Scalar OT entropic regularized cost (float).
|
|
1174
|
+
"""
|
|
1175
|
+
# Amortized OT: cheap momentum steps between full OT solves
|
|
1176
|
+
if (
|
|
1177
|
+
self.amortize_steps > 1
|
|
1178
|
+
and self._amortize_counter % self.amortize_steps != 0
|
|
1179
|
+
and self._transport_direction_ema is not None
|
|
1180
|
+
):
|
|
1181
|
+
result = self._step_momentum(closure)
|
|
1182
|
+
self._amortize_counter += 1
|
|
1183
|
+
return result
|
|
1184
|
+
|
|
1185
|
+
# Full OT step
|
|
1186
|
+
# HybridSubspace now uses monolithic step with per-layer projections
|
|
1187
|
+
# (per-layer OT in _step_hybrid did not achieve target accuracy)
|
|
1188
|
+
if self._subspace_blockwise:
|
|
1189
|
+
# Combined subspace + block-wise mode
|
|
1190
|
+
result = self._step_subspace_blockwise(closure)
|
|
1191
|
+
elif self._blocks is not None:
|
|
1192
|
+
result = self._step_blockwise(closure)
|
|
1193
|
+
else:
|
|
1194
|
+
result = self._step_monolithic(closure)
|
|
1195
|
+
|
|
1196
|
+
self._amortize_counter += 1
|
|
1197
|
+
return result
|
|
1198
|
+
|
|
1199
|
+
# ------------------------------------------------------------------
|
|
1200
|
+
# Step methods: delegated to extracted modules for maintainability.
|
|
1201
|
+
# See _step_monolithic.py, _step_blockwise.py, _step_momentum.py.
|
|
1202
|
+
# ------------------------------------------------------------------
|
|
1203
|
+
|
|
1204
|
+
def _step_monolithic(self, closure: Callable) -> float:
|
|
1205
|
+
"""Monolithic step: single OT solve over all particles.
|
|
1206
|
+
|
|
1207
|
+
Delegates to ``_step_monolithic.step_monolithic()``.
|
|
1208
|
+
"""
|
|
1209
|
+
return _step_monolithic_fn(self, closure)
|
|
1210
|
+
|
|
1211
|
+
def _step_blockwise(self, closure: Callable) -> float:
|
|
1212
|
+
"""Block-wise step: per-block OT solve with full-model closure calls.
|
|
1213
|
+
|
|
1214
|
+
Delegates to ``_step_blockwise.step_blockwise()``.
|
|
1215
|
+
"""
|
|
1216
|
+
return _step_blockwise_fn(self, closure)
|
|
1217
|
+
|
|
1218
|
+
def _step_subspace_blockwise(self, closure: Callable) -> float:
|
|
1219
|
+
"""Combined subspace + block-wise step: per-block OT in subspace coords.
|
|
1220
|
+
|
|
1221
|
+
Delegates to ``_step_blockwise.step_subspace_blockwise()``.
|
|
1222
|
+
"""
|
|
1223
|
+
return _step_subspace_blockwise_fn(self, closure)
|
|
1224
|
+
|
|
1225
|
+
def _step_momentum(self, closure: Callable) -> float:
|
|
1226
|
+
"""Cheap momentum step: apply EMA transport direction with decay.
|
|
1227
|
+
|
|
1228
|
+
Delegates to ``_step_momentum.step_momentum()``.
|
|
1229
|
+
"""
|
|
1230
|
+
return _step_momentum_fn(self, closure)
|
|
1231
|
+
|
|
1232
|
+
def _evaluate_current_loss(self, closure: Callable) -> float:
|
|
1233
|
+
"""Evaluate model loss at current particle position.
|
|
1234
|
+
|
|
1235
|
+
Delegates to ``_step_momentum.evaluate_current_loss()``.
|
|
1236
|
+
"""
|
|
1237
|
+
return _evaluate_current_loss_fn(self, closure)
|
|
1238
|
+
|
|
1239
|
+
# ------------------------------------------------------------------
|
|
1240
|
+
# Rank transition
|
|
1241
|
+
# ------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
def _transition_rank(self, new_rank: int) -> None:
|
|
1244
|
+
"""Transition subspace to new rank, preserving accumulated progress via absorb.
|
|
1245
|
+
|
|
1246
|
+
Absorbs the current perturbation into base weights, then reconstructs
|
|
1247
|
+
the subspace at the new rank. Resets particles, duals, and displacement
|
|
1248
|
+
history to match the new subspace dimension.
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
new_rank: Target rank for the new subspace.
|
|
1252
|
+
"""
|
|
1253
|
+
state = self._state
|
|
1254
|
+
|
|
1255
|
+
# 1. Absorb current perturbation into base weights
|
|
1256
|
+
old_subspace = self.subspace
|
|
1257
|
+
if isinstance(old_subspace, HybridSubspace):
|
|
1258
|
+
flat_sub = state.X.reshape(-1)[: old_subspace.subspace_dim]
|
|
1259
|
+
new_base, _ = old_subspace.absorb(
|
|
1260
|
+
state.hybrid_projections,
|
|
1261
|
+
state.base_params,
|
|
1262
|
+
flat_sub,
|
|
1263
|
+
)
|
|
1264
|
+
state.base_params = new_base
|
|
1265
|
+
elif isinstance(old_subspace, LinearSubspace):
|
|
1266
|
+
flat_sub = state.X.reshape(-1)[: old_subspace.subspace_dim]
|
|
1267
|
+
new_base, _ = old_subspace.absorb(state.base_params, flat_sub)
|
|
1268
|
+
state.base_params = new_base
|
|
1269
|
+
else:
|
|
1270
|
+
# Other subspace types: skip transition
|
|
1271
|
+
import warnings
|
|
1272
|
+
|
|
1273
|
+
warnings.warn(f"Rank transition not supported for {type(old_subspace).__name__}, skipping")
|
|
1274
|
+
return
|
|
1275
|
+
|
|
1276
|
+
# 2. Reconstruct subspace at new rank
|
|
1277
|
+
if isinstance(old_subspace, HybridSubspace):
|
|
1278
|
+
self.subspace = HybridSubspace.from_layout(
|
|
1279
|
+
self.layout,
|
|
1280
|
+
rank=new_rank,
|
|
1281
|
+
seed=old_subspace.seed,
|
|
1282
|
+
rotation_mode=old_subspace.rotation_mode,
|
|
1283
|
+
max_subspace_dim=getattr(old_subspace, "_max_subspace_dim", None),
|
|
1284
|
+
)
|
|
1285
|
+
elif isinstance(old_subspace, LinearSubspace):
|
|
1286
|
+
self.subspace = LinearSubspace.from_layout(
|
|
1287
|
+
self.layout,
|
|
1288
|
+
rank=new_rank,
|
|
1289
|
+
seed=old_subspace.seed,
|
|
1290
|
+
max_subspace_dim=getattr(old_subspace, "_max_subspace_dim", None),
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
# Update state reference
|
|
1294
|
+
state.subspace = self.subspace
|
|
1295
|
+
|
|
1296
|
+
# 3. Resize particle array for new subspace dimension
|
|
1297
|
+
sub_dim = self.subspace.subspace_dim
|
|
1298
|
+
pdim = self._subspace_particle_dim
|
|
1299
|
+
padded_sub_dim = ((sub_dim + pdim - 1) // pdim) * pdim
|
|
1300
|
+
new_X = torch.zeros(
|
|
1301
|
+
padded_sub_dim // pdim,
|
|
1302
|
+
pdim,
|
|
1303
|
+
dtype=state.X.dtype,
|
|
1304
|
+
device=state.X.device,
|
|
1305
|
+
)
|
|
1306
|
+
state.X = new_X
|
|
1307
|
+
|
|
1308
|
+
# 4. Reset duals (shape changed)
|
|
1309
|
+
state.f = None
|
|
1310
|
+
state.g = None
|
|
1311
|
+
|
|
1312
|
+
# 5. Re-initialize displacement history at new subspace dimension
|
|
1313
|
+
if isinstance(self.subspace, HybridSubspace):
|
|
1314
|
+
state.displacement_history = torch.zeros(
|
|
1315
|
+
self.subspace.displacement_history_size,
|
|
1316
|
+
self.subspace.subspace_dim,
|
|
1317
|
+
dtype=state.X.dtype,
|
|
1318
|
+
device=state.X.device,
|
|
1319
|
+
)
|
|
1320
|
+
state.displacement_history_idx = 0
|
|
1321
|
+
state.displacement_history_count = 0
|
|
1322
|
+
# Regenerate per-layer projections for new subspace
|
|
1323
|
+
state.hybrid_projections = self.subspace.init_projections(
|
|
1324
|
+
state.X.device,
|
|
1325
|
+
state.X.dtype,
|
|
1326
|
+
)
|
|
1327
|
+
# Update hybrid subspace reference
|
|
1328
|
+
self._hybrid_subspace = self.subspace
|
|
1329
|
+
|
|
1330
|
+
# Update uniform distribution to match new particle count
|
|
1331
|
+
num_points = state.X.shape[0]
|
|
1332
|
+
state.a = torch.ones(num_points, device=state.X.device, dtype=state.X.dtype) / num_points
|
|
1333
|
+
|
|
1334
|
+
# Clear stale adaptive probe state (shape changed with new rank)
|
|
1335
|
+
self._invalidate_reuse_cache()
|
|
1336
|
+
self._transport_direction_ema = None
|
|
1337
|
+
self._transport_direction = None
|
|
1338
|
+
self._newton_direction = None
|
|
1339
|
+
|
|
1340
|
+
logger.info(
|
|
1341
|
+
f"Rank transition: rank={new_rank}, subspace_dim={self.subspace.subspace_dim}, particles={num_points}"
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
# ------------------------------------------------------------------
|
|
1345
|
+
# Model synchronization
|
|
1346
|
+
# ------------------------------------------------------------------
|
|
1347
|
+
|
|
1348
|
+
def _update_sampling_projection(self) -> None:
|
|
1349
|
+
"""Cache the coord-to-param projection for this step.
|
|
1350
|
+
|
|
1351
|
+
Scaled by sqrt(C_diag) when CMA covariance adaptation is on, else the
|
|
1352
|
+
plain projection. Cached at step start so a step's probes and its
|
|
1353
|
+
end-of-step sync share one metric (C_diag only changes at step end).
|
|
1354
|
+
"""
|
|
1355
|
+
state = self._state
|
|
1356
|
+
proj = state.projection
|
|
1357
|
+
if (
|
|
1358
|
+
self._cma_subspace
|
|
1359
|
+
and self.use_covariance_adaptation
|
|
1360
|
+
and isinstance(proj, torch.Tensor)
|
|
1361
|
+
and state.C_diag is not None
|
|
1362
|
+
):
|
|
1363
|
+
proj = self.subspace.apply_covariance_scaling(proj, state.C_diag)
|
|
1364
|
+
self._sampling_projection = proj
|
|
1365
|
+
|
|
1366
|
+
def _sync_model(self) -> None:
|
|
1367
|
+
"""Write current particles back to the model via load_state_dict."""
|
|
1368
|
+
state = self._state
|
|
1369
|
+
|
|
1370
|
+
if self.subspace is not None:
|
|
1371
|
+
# Subspace: flatten multi-particle X back to subspace coords,
|
|
1372
|
+
# then reconstruct full params from base + perturbation.
|
|
1373
|
+
X = state.X # (num_sub_particles, particle_dim)
|
|
1374
|
+
flat_sub = X.reshape(-1)[: state.subspace.subspace_dim]
|
|
1375
|
+
if self._hybrid:
|
|
1376
|
+
# HybridSubspace requires hybrid_projections dict
|
|
1377
|
+
full_sd = state.subspace.apply_perturbation(
|
|
1378
|
+
state.hybrid_projections,
|
|
1379
|
+
state.base_params,
|
|
1380
|
+
flat_sub,
|
|
1381
|
+
)
|
|
1382
|
+
elif self._adaptive or self._cma_subspace:
|
|
1383
|
+
# AdaptiveSubspace and CMAAdaptiveSubspace require projection argument.
|
|
1384
|
+
# Use the step's cached (possibly covariance-scaled) projection so
|
|
1385
|
+
# the sync matches the metric the probes were evaluated in.
|
|
1386
|
+
proj = self._sampling_projection if self._sampling_projection is not None else state.projection
|
|
1387
|
+
full_sd = state.subspace.apply_perturbation(
|
|
1388
|
+
proj,
|
|
1389
|
+
state.base_params,
|
|
1390
|
+
flat_sub,
|
|
1391
|
+
)
|
|
1392
|
+
else:
|
|
1393
|
+
full_sd = state.subspace.apply_perturbation(
|
|
1394
|
+
state.base_params,
|
|
1395
|
+
flat_sub,
|
|
1396
|
+
)
|
|
1397
|
+
self.model.load_state_dict(full_sd, strict=False)
|
|
1398
|
+
else:
|
|
1399
|
+
# Multi-particle mode: X is already (num_particles, particle_dim)
|
|
1400
|
+
particles_2d = state.X
|
|
1401
|
+
if particles_2d.dim() == 1:
|
|
1402
|
+
particles_2d = particles_2d.reshape(-1, self._particle_dim)
|
|
1403
|
+
|
|
1404
|
+
sd = self.layout.unflatten(particles_2d)
|
|
1405
|
+
self.model.load_state_dict(sd, strict=False)
|