polystep 0.7.0__py3-none-any.whl

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