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
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
"""Monolithic step: single OT solve over all particles.
|
|
2
|
+
|
|
3
|
+
Extracted from optimizer.py for maintainability. Called via delegation
|
|
4
|
+
from PolyStepOptimizer.step().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from .costs import scale_cost_matrix
|
|
15
|
+
from .solvers._prelude import sanitize_cost
|
|
16
|
+
from .dynamics import apply_momentum, compute_momentum_coefficient, update_adaptive_radius
|
|
17
|
+
from .geometry import apply_biased_rotation, get_random_rotation_matrices
|
|
18
|
+
from .solvers import SinkhornSolver
|
|
19
|
+
from .solvers.base import SolverResult
|
|
20
|
+
from .adaptive_subspace import AdaptiveSubspace
|
|
21
|
+
from .hybrid_subspace import HybridSubspace
|
|
22
|
+
from .cma import (
|
|
23
|
+
update_evolution_path_sigma,
|
|
24
|
+
compute_heaviside_sigma,
|
|
25
|
+
update_evolution_path_c,
|
|
26
|
+
update_covariance_diagonal,
|
|
27
|
+
update_step_size_csa,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def step_monolithic(opt, closure: Callable) -> float:
|
|
34
|
+
"""Monolithic step: single OT solve over all particles.
|
|
35
|
+
|
|
36
|
+
Multi-particle architecture: X is (P, particle_dim) where P is
|
|
37
|
+
num_particles and particle_dim is typically 2. For each particle i,
|
|
38
|
+
polytope vertices are sampled in particle_dim space. The cost for
|
|
39
|
+
entry (i, v, k) is evaluated by constructing the full model config
|
|
40
|
+
with particle i replaced by the probe position. The OT problem is
|
|
41
|
+
(P, V) which is tractable.
|
|
42
|
+
"""
|
|
43
|
+
state = opt._state
|
|
44
|
+
X = state.X # (P, particle_dim)
|
|
45
|
+
iteration = state.iteration_count
|
|
46
|
+
device = X.device
|
|
47
|
+
|
|
48
|
+
# Cache the coord->param projection for this step (covariance-scaled for CMA).
|
|
49
|
+
opt._update_sampling_projection()
|
|
50
|
+
|
|
51
|
+
# 1. Resolve epsilon and radii
|
|
52
|
+
current_eps = opt._get_epsilon(iteration)
|
|
53
|
+
# Use CSA sigma or heuristic radius_multiplier
|
|
54
|
+
if opt.use_csa and state.use_csa:
|
|
55
|
+
radius_mult = state.sigma
|
|
56
|
+
elif opt.use_adaptive_radius:
|
|
57
|
+
radius_mult = state.radius_multiplier
|
|
58
|
+
else:
|
|
59
|
+
radius_mult = 1.0
|
|
60
|
+
# Scheduled radii: if step_radius/probe_radius has .at(), the schedule
|
|
61
|
+
# handles annealing (no epsilon multiplication). Float values use the
|
|
62
|
+
# original behavior (radius * eps * radius_mult).
|
|
63
|
+
_sr = opt._get_step_radius(iteration)
|
|
64
|
+
_pr = opt._get_probe_radius(iteration)
|
|
65
|
+
_sr_scheduled = hasattr(opt.step_radius, "at")
|
|
66
|
+
_pr_scheduled = hasattr(opt.probe_radius, "at")
|
|
67
|
+
if opt.trust_region:
|
|
68
|
+
step_r = _sr * opt._trust_region_multiplier * (1.0 if _sr_scheduled else current_eps) * radius_mult
|
|
69
|
+
else:
|
|
70
|
+
step_r = _sr * (1.0 if _sr_scheduled else current_eps) * radius_mult
|
|
71
|
+
probe_r = _pr * (1.0 if _pr_scheduled else current_eps) * radius_mult
|
|
72
|
+
|
|
73
|
+
# Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
|
|
74
|
+
probe_r = opt._apply_probe_radius_jitter(probe_r)
|
|
75
|
+
|
|
76
|
+
# Ensure 2D
|
|
77
|
+
if X.dim() == 1:
|
|
78
|
+
X = X.unsqueeze(0)
|
|
79
|
+
P, pdim = X.shape
|
|
80
|
+
|
|
81
|
+
# Move templates to device (cache after first transfer)
|
|
82
|
+
if opt._polytope_vertices.device != device or opt._polytope_vertices.dtype != X.dtype:
|
|
83
|
+
opt._polytope_vertices = opt._polytope_vertices.to(device=device, dtype=X.dtype)
|
|
84
|
+
polytope_verts = opt._polytope_vertices
|
|
85
|
+
if opt._probes.device != device or opt._probes.dtype != X.dtype:
|
|
86
|
+
opt._probes = opt._probes.to(device=device, dtype=X.dtype)
|
|
87
|
+
probes = opt._probes
|
|
88
|
+
V = polytope_verts.shape[0] # num vertices
|
|
89
|
+
K = probes.shape[0] # num probes
|
|
90
|
+
|
|
91
|
+
# Adaptive probe count: reduce K during exploitation
|
|
92
|
+
K_eff = K # effective probe count for this step
|
|
93
|
+
if opt.adaptive_num_probe and iteration >= opt._adaptive_probe_warmup:
|
|
94
|
+
# Check if last 3 OT-step costs are all decreasing (and all > 0)
|
|
95
|
+
costs_history = opt._ot_step_costs
|
|
96
|
+
if len(costs_history) >= 3:
|
|
97
|
+
recent = list(costs_history)[-3:]
|
|
98
|
+
if all(c > 0 for c in recent) and all(recent[i] > recent[i + 1] for i in range(len(recent) - 1)):
|
|
99
|
+
opt._loss_decreasing_count += 1
|
|
100
|
+
else:
|
|
101
|
+
opt._loss_decreasing_count = 0
|
|
102
|
+
|
|
103
|
+
if opt._loss_decreasing_count >= 3:
|
|
104
|
+
K_eff = 1
|
|
105
|
+
|
|
106
|
+
# Select reduced probes if K_eff < K
|
|
107
|
+
if K_eff < K:
|
|
108
|
+
probes = probes[K // 2 : K // 2 + 1] # center scale, shape (1,)
|
|
109
|
+
|
|
110
|
+
# Adaptive probes: decide reuse before generating rotations. A stagnant
|
|
111
|
+
# particle reuses its previous cost row AND its previous rotation, so the
|
|
112
|
+
# reused row still matches the vertices it was evaluated at. Reuse therefore
|
|
113
|
+
# requires a cached rotation of matching shape.
|
|
114
|
+
stagnant_mask = opt._get_stagnant_mask(P)
|
|
115
|
+
_can_reuse = (
|
|
116
|
+
stagnant_mask is not None
|
|
117
|
+
and opt._prev_cost_matrix is not None
|
|
118
|
+
and opt._prev_cost_matrix.shape == (P, V)
|
|
119
|
+
and opt._prev_rot_mats is not None
|
|
120
|
+
and opt._prev_rot_mats.shape == (P, pdim, pdim)
|
|
121
|
+
and opt._prev_k_eff == K_eff
|
|
122
|
+
and opt._prev_step_r == step_r
|
|
123
|
+
)
|
|
124
|
+
if _can_reuse:
|
|
125
|
+
active_mask = ~stagnant_mask
|
|
126
|
+
active_indices = torch.where(active_mask)[0]
|
|
127
|
+
P_active = active_indices.shape[0]
|
|
128
|
+
else:
|
|
129
|
+
active_indices = None
|
|
130
|
+
P_active = P
|
|
131
|
+
|
|
132
|
+
# 2. Generate rotation matrices: (P, pdim, pdim)
|
|
133
|
+
rot_mats = get_random_rotation_matrices(
|
|
134
|
+
P,
|
|
135
|
+
pdim,
|
|
136
|
+
device=device,
|
|
137
|
+
dtype=X.dtype,
|
|
138
|
+
generator=opt._generator,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 2b. Transport-biased rotation: replace first column with previous OT descent direction
|
|
142
|
+
if (
|
|
143
|
+
opt.biased_rotation
|
|
144
|
+
and opt._prev_descent_direction is not None
|
|
145
|
+
and opt._prev_descent_direction.shape == (P, pdim)
|
|
146
|
+
and opt._prev_descent_direction_finite
|
|
147
|
+
):
|
|
148
|
+
bias_dir = opt._prev_descent_direction # (P, pdim)
|
|
149
|
+
bias_norms = torch.norm(bias_dir, dim=-1, keepdim=True).clamp(min=1e-10)
|
|
150
|
+
bias_dir_norm = bias_dir / bias_norms # (P, pdim)
|
|
151
|
+
rot_mats = apply_biased_rotation(rot_mats, bias_dir_norm)
|
|
152
|
+
|
|
153
|
+
# Stagnant particles reuse their previous rotation so the cost rows reused
|
|
154
|
+
# below describe the same vertices those rows were evaluated at.
|
|
155
|
+
if _can_reuse and P_active < P:
|
|
156
|
+
rot_mats[stagnant_mask] = opt._prev_rot_mats[stagnant_mask]
|
|
157
|
+
|
|
158
|
+
# 3. Rotate + translate: X_vertices (P, V, pdim), rotated (P, V, pdim)
|
|
159
|
+
X_vertices, rotated = opt._compiled.rotate_and_translate(
|
|
160
|
+
rot_mats,
|
|
161
|
+
polytope_verts,
|
|
162
|
+
X,
|
|
163
|
+
step_r,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# 4. Probe generation: X_probe (P, V, K, pdim)
|
|
167
|
+
X_probe = opt._compiled.compute_probe_points(
|
|
168
|
+
X,
|
|
169
|
+
rotated,
|
|
170
|
+
probes,
|
|
171
|
+
probe_r,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# 5. Build full model configs and evaluate cost
|
|
175
|
+
# For each (i, v, k), construct a full (P, pdim) config with row i
|
|
176
|
+
# replaced by X_probe[i, v, k]. Then unflatten to params and evaluate.
|
|
177
|
+
|
|
178
|
+
# Stagnant particles (adaptive probes) reuse their previous cost row; their
|
|
179
|
+
# stagnation and reuse were decided up front, before rotation generation.
|
|
180
|
+
|
|
181
|
+
# All-stagnant shortcut: reuse entire previous cost matrix, skip evaluation
|
|
182
|
+
if _can_reuse and P_active == 0:
|
|
183
|
+
cost_matrix = opt._prev_cost_matrix.clone()
|
|
184
|
+
else:
|
|
185
|
+
# More efficient: process all probes for each particle in a batch
|
|
186
|
+
# Total evaluations: P_active * V * K_eff. Each builds full config (P, pdim)
|
|
187
|
+
# flattened to (padded_size,), then batch-unflattened.
|
|
188
|
+
#
|
|
189
|
+
# Strategy: process in chunks to control memory.
|
|
190
|
+
# Build indices for all (active_i, v, k) combinations.
|
|
191
|
+
total_evals = P_active * V * K_eff
|
|
192
|
+
|
|
193
|
+
# batch_configs is (chunk, P, pdim). At chunk == total_evals that is
|
|
194
|
+
# ~O(n_params^2) in full-space mode (P ~ n_params/pdim), which OOMs at the
|
|
195
|
+
# default chunk_size=None. Cap the default to ~256 MB of fp32 configs;
|
|
196
|
+
# chunking only splits the loop, so results are unchanged.
|
|
197
|
+
if opt.chunk_size:
|
|
198
|
+
chunk = opt.chunk_size
|
|
199
|
+
else:
|
|
200
|
+
config_size = max(1, X.numel()) # P * pdim
|
|
201
|
+
chunk = min(total_evals, max(1, (1 << 26) // config_size))
|
|
202
|
+
# Pre-allocate losses tensor to avoid list+cat overhead
|
|
203
|
+
losses = torch.empty(total_evals, dtype=torch.float32, device=device)
|
|
204
|
+
|
|
205
|
+
# Pre-allocate batch_configs buffer for reuse across chunks
|
|
206
|
+
_batch_configs_buf = X.unsqueeze(0).expand(chunk, -1, -1).clone() if chunk <= total_evals else None
|
|
207
|
+
# Pre-allocate local range tensors (avoid repeated arange calls)
|
|
208
|
+
_local_range_full = torch.arange(chunk, device=device) if chunk <= total_evals else None
|
|
209
|
+
|
|
210
|
+
# Cache subspace info for the loop
|
|
211
|
+
_is_subspace = opt.subspace is not None
|
|
212
|
+
_sub_dim = state.subspace.subspace_dim if _is_subspace else 0
|
|
213
|
+
|
|
214
|
+
# Fused subspace reconstruct + in-place forward path: avoids
|
|
215
|
+
# materialising N full weight dicts when the evaluator supports it.
|
|
216
|
+
_use_fused_inplace = (
|
|
217
|
+
opt._hybrid
|
|
218
|
+
and hasattr(state.subspace, "apply_perturbation_inplace")
|
|
219
|
+
and hasattr(opt, "_cost_evaluator")
|
|
220
|
+
and getattr(opt._cost_evaluator, "_use_inplace", False)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
_all_indices = torch.arange(total_evals, device=device)
|
|
224
|
+
|
|
225
|
+
for chunk_start in range(0, total_evals, chunk):
|
|
226
|
+
chunk_end = min(chunk_start + chunk, total_evals)
|
|
227
|
+
chunk_size_actual = chunk_end - chunk_start
|
|
228
|
+
|
|
229
|
+
# Vectorized index computation (replaces Python for-loop)
|
|
230
|
+
global_indices = _all_indices[chunk_start:chunk_end] # view, no alloc
|
|
231
|
+
active_idx = global_indices // (V * K_eff)
|
|
232
|
+
vk = global_indices % (V * K_eff)
|
|
233
|
+
v_idx = vk // K_eff
|
|
234
|
+
k_idx = vk % K_eff
|
|
235
|
+
|
|
236
|
+
if active_indices is not None:
|
|
237
|
+
i_idx = active_indices[active_idx]
|
|
238
|
+
else:
|
|
239
|
+
i_idx = active_idx
|
|
240
|
+
|
|
241
|
+
# Reuse pre-allocated buffer when chunk size matches, otherwise allocate
|
|
242
|
+
if _batch_configs_buf is not None and chunk_size_actual == chunk:
|
|
243
|
+
batch_configs = _batch_configs_buf.copy_(X.unsqueeze(0).expand(chunk, -1, -1))
|
|
244
|
+
local_range = _local_range_full
|
|
245
|
+
else:
|
|
246
|
+
batch_configs = X.unsqueeze(0).expand(chunk_size_actual, -1, -1).clone()
|
|
247
|
+
local_range = torch.arange(chunk_size_actual, device=device)
|
|
248
|
+
batch_configs[local_range, i_idx] = X_probe[i_idx, v_idx, k_idx]
|
|
249
|
+
|
|
250
|
+
# Flatten each config to (padded_size,)
|
|
251
|
+
flat_configs = batch_configs.reshape(chunk_size_actual, -1)
|
|
252
|
+
# flat_configs: (chunk_size, P * pdim)
|
|
253
|
+
|
|
254
|
+
if _use_fused_inplace and _is_subspace and opt._hybrid:
|
|
255
|
+
# Fused path (EGGROLL-inspired): reconstruct + forward one
|
|
256
|
+
# config at a time via in-place weight swap. Never materializes
|
|
257
|
+
# the full (N, *param_shape) stacked dict. Memory: O(1 x activation).
|
|
258
|
+
flat_sub = flat_configs[:, :_sub_dim]
|
|
259
|
+
if opt._mixed_precision and getattr(state, "projection", None) is not None:
|
|
260
|
+
flat_sub = flat_sub.to(dtype=state.projection.dtype)
|
|
261
|
+
chunk_losses = opt._cost_evaluator.evaluate_subspace_inplace(
|
|
262
|
+
state.subspace,
|
|
263
|
+
state.hybrid_projections,
|
|
264
|
+
state.base_params,
|
|
265
|
+
flat_sub,
|
|
266
|
+
opt._fused_inputs,
|
|
267
|
+
opt._fused_targets,
|
|
268
|
+
)
|
|
269
|
+
elif _is_subspace:
|
|
270
|
+
# Subspace mode: reconstruct full params from subspace coords
|
|
271
|
+
flat_sub = flat_configs[:, :_sub_dim]
|
|
272
|
+
if opt._mixed_precision and state.projection is not None:
|
|
273
|
+
flat_sub = flat_sub.to(dtype=state.projection.dtype)
|
|
274
|
+
if opt._adaptive or opt._cma_subspace:
|
|
275
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
276
|
+
opt._sampling_projection,
|
|
277
|
+
state.base_params,
|
|
278
|
+
flat_sub,
|
|
279
|
+
)
|
|
280
|
+
elif opt._hybrid:
|
|
281
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
282
|
+
state.hybrid_projections,
|
|
283
|
+
state.base_params,
|
|
284
|
+
flat_sub,
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
288
|
+
state.base_params,
|
|
289
|
+
flat_sub,
|
|
290
|
+
)
|
|
291
|
+
chunk_losses = closure(chunk_params)
|
|
292
|
+
else:
|
|
293
|
+
# Trim to layout padded_size if needed
|
|
294
|
+
layout_flat = opt.layout.padded_size
|
|
295
|
+
if flat_configs.shape[1] >= layout_flat:
|
|
296
|
+
flat_for_layout = flat_configs[:, :layout_flat]
|
|
297
|
+
else:
|
|
298
|
+
flat_for_layout = torch.nn.functional.pad(
|
|
299
|
+
flat_configs,
|
|
300
|
+
(0, layout_flat - flat_configs.shape[1]),
|
|
301
|
+
)
|
|
302
|
+
chunk_params = opt.layout.batch_unflatten(flat_for_layout)
|
|
303
|
+
chunk_losses = closure(chunk_params)
|
|
304
|
+
|
|
305
|
+
# Write directly into pre-allocated tensor (FP32 for Sinkhorn stability)
|
|
306
|
+
losses[chunk_start:chunk_end] = chunk_losses.float()
|
|
307
|
+
|
|
308
|
+
if _can_reuse and P_active < P:
|
|
309
|
+
# Build full cost matrix: reuse previous rows for stagnant particles,
|
|
310
|
+
# fill active rows from fresh evaluations
|
|
311
|
+
if K_eff == 1:
|
|
312
|
+
# K=1 fast path: no averaging needed
|
|
313
|
+
active_cost = losses.reshape(P_active, V)
|
|
314
|
+
else:
|
|
315
|
+
active_losses_3d = losses.reshape(P_active, V, K_eff)
|
|
316
|
+
active_cost = active_losses_3d.mean(dim=-1) # (P_active, V)
|
|
317
|
+
cost_matrix = opt._prev_cost_matrix.clone()
|
|
318
|
+
cost_matrix[active_indices] = active_cost
|
|
319
|
+
# Retain full losses_3d for quadratic model (merge active into prev)
|
|
320
|
+
if opt.use_quadratic_model and opt._prev_losses_3d is not None:
|
|
321
|
+
losses_3d_full = opt._prev_losses_3d.clone()
|
|
322
|
+
if K_eff == 1:
|
|
323
|
+
losses_3d_full[active_indices] = losses.reshape(P_active, V, 1)
|
|
324
|
+
else:
|
|
325
|
+
losses_3d_full[active_indices] = active_losses_3d
|
|
326
|
+
opt._prev_losses_3d = losses_3d_full.detach()
|
|
327
|
+
elif opt.use_quadratic_model:
|
|
328
|
+
opt._prev_losses_3d = None # no full data yet
|
|
329
|
+
else:
|
|
330
|
+
if K_eff == 1:
|
|
331
|
+
# K=1 fast path: no averaging needed
|
|
332
|
+
cost_matrix = losses.reshape(P, V)
|
|
333
|
+
if opt.use_quadratic_model:
|
|
334
|
+
opt._prev_losses_3d = losses.reshape(P, V, 1).detach()
|
|
335
|
+
else:
|
|
336
|
+
losses_3d_full = losses.reshape(P, V, K_eff)
|
|
337
|
+
cost_matrix = losses_3d_full.mean(dim=-1) # (P, V)
|
|
338
|
+
if opt.use_quadratic_model:
|
|
339
|
+
opt._prev_losses_3d = losses_3d_full.detach()
|
|
340
|
+
|
|
341
|
+
# Sanitize cost (FP32 promote + finite penalty), branch-free / no host sync.
|
|
342
|
+
# Protects the downstream trust-region / multifidelity readers and BOTH
|
|
343
|
+
# solver paths -- the fused softmax scales this same sanitized tensor.
|
|
344
|
+
cost_matrix = sanitize_cost(cost_matrix)
|
|
345
|
+
|
|
346
|
+
# Raw cost for next step's contrast/trust-region baseline; dampening below
|
|
347
|
+
# only shapes the current OT solve.
|
|
348
|
+
raw_cost_matrix = cost_matrix
|
|
349
|
+
|
|
350
|
+
# Deferred trust-region update: the cost matrix at this step is evaluated
|
|
351
|
+
# at the particle position produced by the *previous* step. Comparing the
|
|
352
|
+
# prediction stored on the previous step against this step's pre-OT min cost
|
|
353
|
+
# gives a real predicted-vs-actual reduction ratio.
|
|
354
|
+
if opt.trust_region and opt._prev_predicted_improvement is not None and opt._prev_pre_step_loss is not None:
|
|
355
|
+
current_loss_proxy = cost_matrix.min(dim=1).values.mean().item()
|
|
356
|
+
# Sign convention matches update_trust_region and predicted improvement:
|
|
357
|
+
# negative means loss decreased.
|
|
358
|
+
actual_improvement = torch.tensor([current_loss_proxy - opt._prev_pre_step_loss])
|
|
359
|
+
from .quadratic_model import update_trust_region
|
|
360
|
+
|
|
361
|
+
opt._trust_region_multiplier = update_trust_region(
|
|
362
|
+
opt._prev_predicted_improvement,
|
|
363
|
+
actual_improvement,
|
|
364
|
+
opt._trust_region_multiplier,
|
|
365
|
+
min_radius=0.1,
|
|
366
|
+
max_radius=3.0,
|
|
367
|
+
)
|
|
368
|
+
state.trust_region_multipliers.append(opt._trust_region_multiplier)
|
|
369
|
+
opt._prev_predicted_improvement = None
|
|
370
|
+
opt._prev_pre_step_loss = None
|
|
371
|
+
|
|
372
|
+
# Multi-fidelity screening: dampen low-contrast vertex directions
|
|
373
|
+
# Uses previous step's cost to identify uninformative directions and
|
|
374
|
+
# blend their cost toward the row mean, making OT focus on informative ones.
|
|
375
|
+
if opt.multifidelity_screen and opt._prev_cost_matrix is not None and opt.polytope_type == "orthoplex":
|
|
376
|
+
prev_cost = opt._prev_cost_matrix # (P, V)
|
|
377
|
+
if prev_cost.shape == (P, V):
|
|
378
|
+
pdim_local = V // 2
|
|
379
|
+
# Cost contrast: |L(+e_i) - L(-e_i)| for each direction
|
|
380
|
+
dir_contrast = (prev_cost[:, :pdim_local] - prev_cost[:, pdim_local : 2 * pdim_local]).abs()
|
|
381
|
+
# Mean contrast across particles per direction
|
|
382
|
+
mean_contrast = dir_contrast.mean(dim=0) # (pdim_local,)
|
|
383
|
+
# Threshold: keep top screen_keep_ratio directions at full weight
|
|
384
|
+
keep_k = max(1, int(pdim_local * opt.screen_keep_ratio))
|
|
385
|
+
if keep_k < pdim_local:
|
|
386
|
+
threshold = mean_contrast.topk(keep_k)[0][-1]
|
|
387
|
+
# Dampen low-contrast directions: push their cost toward row mean
|
|
388
|
+
dampen_mask = mean_contrast < threshold # (pdim_local,) bool
|
|
389
|
+
# Expand to vertex mask: dampen both +e_i and -e_i
|
|
390
|
+
vertex_dampen = torch.zeros(V, dtype=torch.bool, device=cost_matrix.device)
|
|
391
|
+
vertex_dampen[:pdim_local] = dampen_mask
|
|
392
|
+
vertex_dampen[pdim_local:] = dampen_mask
|
|
393
|
+
# Dampen: blend dampened vertices toward row mean
|
|
394
|
+
row_mean = cost_matrix.mean(dim=1, keepdim=True) # (P, 1)
|
|
395
|
+
dampen_factor = 0.8 # blend 80% toward mean
|
|
396
|
+
cost_matrix = cost_matrix.clone()
|
|
397
|
+
cost_matrix[:, vertex_dampen] = (1 - dampen_factor) * cost_matrix[
|
|
398
|
+
:, vertex_dampen
|
|
399
|
+
] + dampen_factor * row_mean.expand_as(cost_matrix)[:, vertex_dampen]
|
|
400
|
+
|
|
401
|
+
# 6. Resolve OT epsilon
|
|
402
|
+
ent_eps = opt._get_ent_epsilon(iteration)
|
|
403
|
+
ot_epsilon = ent_eps if ent_eps is not None else current_eps
|
|
404
|
+
|
|
405
|
+
# If the schedule jumped epsilon by more than 2x in one step,
|
|
406
|
+
# invalidate the dual-momentum history. prev_prev_f / prev_prev_g
|
|
407
|
+
# were computed at the old epsilon; extrapolating across a big
|
|
408
|
+
# epsilon change pushes the warm-start far from the new fixed
|
|
409
|
+
# point and the next solve has to undo the bad init.
|
|
410
|
+
if state.last_solve_eps is not None and (
|
|
411
|
+
ot_epsilon / state.last_solve_eps > 2.0 or state.last_solve_eps / ot_epsilon > 2.0
|
|
412
|
+
):
|
|
413
|
+
state.prev_prev_f = None
|
|
414
|
+
state.prev_prev_g = None
|
|
415
|
+
|
|
416
|
+
# 7. Dual potential momentum: extrapolate warm-start duals
|
|
417
|
+
init_f_for_solve = state.f
|
|
418
|
+
init_g_for_solve = state.g
|
|
419
|
+
if (
|
|
420
|
+
opt._dual_momentum_beta > 0.0
|
|
421
|
+
and state.f is not None
|
|
422
|
+
and state.prev_prev_f is not None
|
|
423
|
+
and state.prev_prev_g is not None
|
|
424
|
+
):
|
|
425
|
+
beta = opt._dual_momentum_beta
|
|
426
|
+
init_f_for_solve = state.f + beta * (state.f - state.prev_prev_f)
|
|
427
|
+
init_g_for_solve = state.g + beta * (state.g - state.prev_prev_g)
|
|
428
|
+
# Clamp to prevent overflow (same bounds as warm-start validation)
|
|
429
|
+
max_abs = 80.0 * max(ot_epsilon, 0.01)
|
|
430
|
+
init_f_for_solve = init_f_for_solve.clamp(-max_abs, max_abs)
|
|
431
|
+
init_g_for_solve = init_g_for_solve.clamp(-max_abs, max_abs)
|
|
432
|
+
|
|
433
|
+
# 7b. OT solve
|
|
434
|
+
opt.solver.epsilon = ot_epsilon
|
|
435
|
+
if opt._use_fused_softmax:
|
|
436
|
+
# Fused path: softmax + vertex-free projection in one compiled call
|
|
437
|
+
# Scale the cost outside the compiled kernel so every scale_cost mode
|
|
438
|
+
# ('mean'/'max_cost'/float/None) matches the non-fused solvers; the
|
|
439
|
+
# kernel then runs on the already-scaled cost (scale_cost_mean=False).
|
|
440
|
+
scaled_cost = scale_cost_matrix(cost_matrix, opt.scale_cost)
|
|
441
|
+
X_new_fused, transport_matrix = opt._compiled.fused_softmax_project(
|
|
442
|
+
scaled_cost,
|
|
443
|
+
ot_epsilon,
|
|
444
|
+
state.a,
|
|
445
|
+
opt._polytope_vertices,
|
|
446
|
+
rot_mats,
|
|
447
|
+
step_r,
|
|
448
|
+
X,
|
|
449
|
+
scale_cost_mean=False,
|
|
450
|
+
)
|
|
451
|
+
# state.costs tracks cost_matrix.mean, so the OT cost is unused here.
|
|
452
|
+
ot_result = SolverResult(
|
|
453
|
+
matrix=transport_matrix,
|
|
454
|
+
cost=0.0,
|
|
455
|
+
f=None,
|
|
456
|
+
g=None,
|
|
457
|
+
converged=True,
|
|
458
|
+
n_iters=1,
|
|
459
|
+
ent_reg_cost=0.0,
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
# Forward the previous solve's epsilon so SinkhornSolver
|
|
463
|
+
# can rescale the warm-started duals when epsilon changed.
|
|
464
|
+
# a defaults to uniform 1/P inside the solver, which equals state.a; pass
|
|
465
|
+
# None so the solver skips the host-syncing user-marginal validation.
|
|
466
|
+
solve_kwargs = dict(
|
|
467
|
+
cost_matrix=cost_matrix,
|
|
468
|
+
init_f=init_f_for_solve,
|
|
469
|
+
init_g=init_g_for_solve,
|
|
470
|
+
scale_cost=opt.scale_cost,
|
|
471
|
+
)
|
|
472
|
+
if isinstance(opt.solver, SinkhornSolver) and state.last_solve_eps is not None:
|
|
473
|
+
solve_kwargs["init_eps"] = state.last_solve_eps
|
|
474
|
+
ot_result = opt.solver.solve(**solve_kwargs)
|
|
475
|
+
state.last_solve_eps = ot_epsilon
|
|
476
|
+
|
|
477
|
+
# Auto-epsilon feedback: update progressive epsilon from solver stats
|
|
478
|
+
if opt._progressive_epsilon is not None:
|
|
479
|
+
opt._progressive_epsilon.update(
|
|
480
|
+
n_iters=ot_result.n_iters,
|
|
481
|
+
max_iterations=getattr(opt.solver, "max_iterations", 1),
|
|
482
|
+
converged=ot_result.converged,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# 7b. Save pre-step subspace coords for AdaptiveSubspace displacement
|
|
486
|
+
# history and CMA evolution paths. Declared here, populated conditionally.
|
|
487
|
+
_pre_step_sub_coords = None
|
|
488
|
+
|
|
489
|
+
if opt._adaptive or opt._hybrid or (opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa)):
|
|
490
|
+
sub_dim = opt.subspace.subspace_dim
|
|
491
|
+
_pre_step_sub_coords = state.X.reshape(-1)[:sub_dim].clone()
|
|
492
|
+
|
|
493
|
+
# 7c. Capture X_vertices and X for OT-bias rotation
|
|
494
|
+
# ORDERING: This capture MUST happen:
|
|
495
|
+
# - AFTER X_vertices computation (step 3, line ~427)
|
|
496
|
+
# - AFTER OT solve (step 7, line ~586) so ot_result.matrix exists
|
|
497
|
+
# - BEFORE barycentric projection (step 8, line ~614) which updates state.X
|
|
498
|
+
_X_vertices_for_ot_bias = X_vertices # (P, V, pdim) - already computed at step 3
|
|
499
|
+
_X_pre_barycentric = X.clone() # (P, pdim) - clone to preserve pre-update state
|
|
500
|
+
|
|
501
|
+
# 7d. Compute rotation bias direction
|
|
502
|
+
# The finite-difference model feeds two independent features: biased_rotation
|
|
503
|
+
# (descent/Newton directions) and trust_region (predicted-vs-actual ratio).
|
|
504
|
+
# Build it whenever either is active so trust_region no longer needs
|
|
505
|
+
# biased_rotation to be on.
|
|
506
|
+
_quad_ready = (
|
|
507
|
+
opt.use_quadratic_model
|
|
508
|
+
and opt._prev_losses_3d is not None
|
|
509
|
+
and K_eff >= 2
|
|
510
|
+
and opt._prev_losses_3d.shape == (P, V, K_eff)
|
|
511
|
+
and opt.polytope_type == "orthoplex" # FD extractors assume orthoplex vertex order
|
|
512
|
+
)
|
|
513
|
+
if (opt.biased_rotation or opt.trust_region) and _quad_ready:
|
|
514
|
+
from .quadratic_model import compute_newton_step, extract_fd_gradient, extract_fd_hessian_diag
|
|
515
|
+
|
|
516
|
+
# FD gradient and diagonal Hessian in the rotated frame.
|
|
517
|
+
fd_grad = extract_fd_gradient(opt._prev_losses_3d, probes, probe_r, pdim) # (P, pdim)
|
|
518
|
+
fd_hess = extract_fd_hessian_diag(opt._prev_losses_3d, probes, probe_r, pdim) # (P, pdim)
|
|
519
|
+
newton_rot = compute_newton_step(fd_grad, fd_hess, max_step_norm=step_r, hessian_reg=1e-4) # (P, pdim)
|
|
520
|
+
|
|
521
|
+
if opt.biased_rotation:
|
|
522
|
+
# Descent direction = negative gradient in original space (rot_mats @ .).
|
|
523
|
+
grad_orig = torch.einsum("bij,bj->bi", rot_mats, fd_grad) # (P, pdim)
|
|
524
|
+
opt._prev_descent_direction = -grad_orig.detach()
|
|
525
|
+
opt._prev_descent_direction_finite = True
|
|
526
|
+
newton_orig = torch.einsum("bij,bj->bi", rot_mats, newton_rot)
|
|
527
|
+
opt._newton_direction = newton_orig.detach()
|
|
528
|
+
|
|
529
|
+
if opt.trust_region:
|
|
530
|
+
# Store predicted improvement and pre-step proxy loss. Both are
|
|
531
|
+
# consumed at the start of the next ``step`` call, where the actual
|
|
532
|
+
# loss reduction is measured from the new cost matrix.
|
|
533
|
+
from .quadratic_model import compute_predicted_improvement
|
|
534
|
+
|
|
535
|
+
opt._prev_predicted_improvement = compute_predicted_improvement(fd_grad, fd_hess, newton_rot).detach()
|
|
536
|
+
opt._prev_pre_step_loss = raw_cost_matrix.min(dim=1).values.mean().item()
|
|
537
|
+
elif opt.biased_rotation:
|
|
538
|
+
# Fallback: OT descent direction when the quadratic model is unavailable.
|
|
539
|
+
transport_weights = ot_result.matrix # (P, V)
|
|
540
|
+
vertex_offsets = X_vertices - X.unsqueeze(1) # (P, V, pdim)
|
|
541
|
+
weight_sums = transport_weights.sum(dim=1, keepdim=True).clamp(min=1e-10)
|
|
542
|
+
normalized_weights = transport_weights / weight_sums # (P, V)
|
|
543
|
+
weighted_dir = (normalized_weights.unsqueeze(-1) * vertex_offsets).sum(dim=1) # (P, pdim)
|
|
544
|
+
opt._prev_descent_direction = weighted_dir.detach()
|
|
545
|
+
opt._prev_descent_direction_finite = True
|
|
546
|
+
|
|
547
|
+
# 8. Barycentric projection: (P, pdim)
|
|
548
|
+
if opt._use_fused_softmax:
|
|
549
|
+
X_bary = X_new_fused # Already computed by fused function
|
|
550
|
+
else:
|
|
551
|
+
X_bary = opt._compiled.barycentric_projection(
|
|
552
|
+
ot_result.matrix,
|
|
553
|
+
state.a,
|
|
554
|
+
X_vertices,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# 9. Momentum
|
|
558
|
+
if opt.use_momentum and state.velocity is not None:
|
|
559
|
+
beta = compute_momentum_coefficient(
|
|
560
|
+
iteration,
|
|
561
|
+
opt.max_iterations,
|
|
562
|
+
opt.momentum_init,
|
|
563
|
+
opt.momentum_final,
|
|
564
|
+
)
|
|
565
|
+
X_new, vel_new = apply_momentum(
|
|
566
|
+
X,
|
|
567
|
+
X_bary,
|
|
568
|
+
state.velocity,
|
|
569
|
+
beta,
|
|
570
|
+
opt.velocity_lr,
|
|
571
|
+
)
|
|
572
|
+
state.velocity = vel_new
|
|
573
|
+
state.X = X_new
|
|
574
|
+
else:
|
|
575
|
+
state.X = X_bary
|
|
576
|
+
|
|
577
|
+
# 9a. Newton refinement: post-OT correction using quadratic model
|
|
578
|
+
# Tracks whether a post-solve move (refinement) makes the solve's duals stale.
|
|
579
|
+
_duals_invalidated = False
|
|
580
|
+
if opt._newton_refinement and opt._prev_losses_3d is not None and K_eff >= 2 and opt.polytope_type == "orthoplex":
|
|
581
|
+
from .quadratic_model import apply_newton_refinement
|
|
582
|
+
|
|
583
|
+
X_refined = apply_newton_refinement(
|
|
584
|
+
X_bary=state.X,
|
|
585
|
+
losses_3d=opt._prev_losses_3d,
|
|
586
|
+
scales=probes,
|
|
587
|
+
probe_radius=probe_r,
|
|
588
|
+
pdim=pdim,
|
|
589
|
+
rot_mats=rot_mats,
|
|
590
|
+
X_current=X,
|
|
591
|
+
alpha=opt._newton_refinement_alpha,
|
|
592
|
+
max_step_norm=step_r * 0.5,
|
|
593
|
+
hessian_reg=1e-4,
|
|
594
|
+
)
|
|
595
|
+
if torch.isfinite(X_refined).all():
|
|
596
|
+
state.X = X_refined
|
|
597
|
+
# Newton refinement moved particles, so the solve's dual potentials
|
|
598
|
+
# encode the old positions. Mark them stale so the dual-save block
|
|
599
|
+
# below does not warm-start the next step from them.
|
|
600
|
+
_duals_invalidated = True
|
|
601
|
+
|
|
602
|
+
# 9b. CMA-ES updates
|
|
603
|
+
if opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa):
|
|
604
|
+
cma_sub = opt.subspace # CMAAdaptiveSubspace
|
|
605
|
+
sub_dim = cma_sub.subspace_dim
|
|
606
|
+
|
|
607
|
+
# Compute mean displacement in subspace coordinates
|
|
608
|
+
post_step_coords = state.X.reshape(-1)[:sub_dim]
|
|
609
|
+
raw_displacement = post_step_coords - _pre_step_sub_coords
|
|
610
|
+
|
|
611
|
+
# Sigma-normalize the raw subspace step to the isotropic z coordinate.
|
|
612
|
+
normalized_displacement = raw_displacement / state.sigma
|
|
613
|
+
# Sampling scales the projection columns by sqrt(C_diag), so the offspring
|
|
614
|
+
# step in covariance-metric coordinates is y = sqrt(C_diag) * z. The
|
|
615
|
+
# evolution paths and rank-mu are defined on y, not the raw z.
|
|
616
|
+
sqrt_C = torch.sqrt(torch.clamp(state.C_diag, min=opt._cma_params["cov_min"]))
|
|
617
|
+
y_displacement = sqrt_C * normalized_displacement
|
|
618
|
+
|
|
619
|
+
# 1. Update p_sigma; its internal C^{-1/2} whitens y back to z.
|
|
620
|
+
state.p_sigma = update_evolution_path_sigma(
|
|
621
|
+
p_sigma=state.p_sigma,
|
|
622
|
+
displacement=y_displacement,
|
|
623
|
+
C_diag=state.C_diag,
|
|
624
|
+
c_sigma=opt._cma_params["c_sigma"],
|
|
625
|
+
mu_eff=opt._cma_params["mu_eff"],
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# 2. Compute Heaviside for stall detection. Compare ||p_sigma|| to its
|
|
629
|
+
# running mean, then fold the current norm in. This tracks the OT step
|
|
630
|
+
# scale instead of the Gaussian sqrt(n), which OT never reaches.
|
|
631
|
+
p_sigma_norm = torch.norm(state.p_sigma).item()
|
|
632
|
+
prev_ema = state.csa_norm_ema
|
|
633
|
+
csa_expected_norm = max(prev_ema if prev_ema is not None else p_sigma_norm, 1e-8)
|
|
634
|
+
state.csa_norm_ema = p_sigma_norm if prev_ema is None else 0.9 * prev_ema + 0.1 * p_sigma_norm
|
|
635
|
+
h_sigma = compute_heaviside_sigma(
|
|
636
|
+
p_sigma_norm=p_sigma_norm,
|
|
637
|
+
expected_norm=csa_expected_norm,
|
|
638
|
+
n=sub_dim,
|
|
639
|
+
c_sigma=opt._cma_params["c_sigma"],
|
|
640
|
+
generation=state.generation,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# 3. Update p_c (covariance evolution path) on the y-scaled step.
|
|
644
|
+
state.p_c = update_evolution_path_c(
|
|
645
|
+
p_c=state.p_c,
|
|
646
|
+
displacement=y_displacement,
|
|
647
|
+
h_sigma=h_sigma,
|
|
648
|
+
c_c=opt._cma_params["c_c"],
|
|
649
|
+
mu_eff=opt._cma_params["mu_eff"],
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# 4. Update diagonal covariance with rank-mu (if enabled)
|
|
653
|
+
if opt.use_covariance_adaptation:
|
|
654
|
+
# Rank-mu offspring are the polytope vertices; the transport masses
|
|
655
|
+
# are the recombination weights. Each subspace coordinate belongs to
|
|
656
|
+
# one particle, so its variance is the transport-weighted mean of its
|
|
657
|
+
# V squared vertex steps, laid out to match the flattened C_diag.
|
|
658
|
+
vertex_steps = (X_vertices - X.unsqueeze(1)) / state.sigma # (P, V, pdim), raw z
|
|
659
|
+
transport = ot_result.matrix # (P, V)
|
|
660
|
+
recomb = transport / transport.sum(dim=1, keepdim=True).clamp(min=1e-12)
|
|
661
|
+
rank_mu_local = (recomb.unsqueeze(-1) * vertex_steps**2).sum(dim=1) # (P, pdim)
|
|
662
|
+
# rank-mu is sum_v w_v * y_v^2 = C_diag * sum_v w_v * z_v^2.
|
|
663
|
+
rank_mu = state.C_diag * rank_mu_local.reshape(-1)[:sub_dim]
|
|
664
|
+
|
|
665
|
+
state.C_diag = update_covariance_diagonal(
|
|
666
|
+
C_diag=state.C_diag,
|
|
667
|
+
p_c=state.p_c,
|
|
668
|
+
rank_mu=rank_mu,
|
|
669
|
+
c_1=opt._cma_params["c_1"],
|
|
670
|
+
c_mu=opt._cma_params["c_mu"],
|
|
671
|
+
h_sigma=h_sigma,
|
|
672
|
+
c_c=opt._cma_params["c_c"],
|
|
673
|
+
)
|
|
674
|
+
state.C_diag = torch.clamp(
|
|
675
|
+
state.C_diag,
|
|
676
|
+
opt._cma_params["cov_min"],
|
|
677
|
+
opt._cma_params["cov_max"],
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# 5. Update step-size via CSA (if enabled). Reuse p_sigma_norm and the
|
|
681
|
+
# calibrated expected norm from the Heaviside check above.
|
|
682
|
+
if opt.use_csa:
|
|
683
|
+
state.sigma = update_step_size_csa(
|
|
684
|
+
sigma=state.sigma,
|
|
685
|
+
p_sigma=state.p_sigma,
|
|
686
|
+
c_sigma=opt._cma_params["c_sigma"],
|
|
687
|
+
d_sigma=opt._cma_params["d_sigma"],
|
|
688
|
+
n=sub_dim,
|
|
689
|
+
p_sigma_norm=p_sigma_norm,
|
|
690
|
+
expected_norm=csa_expected_norm,
|
|
691
|
+
)
|
|
692
|
+
# Floor sigma to prevent collapse to zero
|
|
693
|
+
state.sigma = max(state.sigma, 1e-6)
|
|
694
|
+
|
|
695
|
+
# 6. Increment generation
|
|
696
|
+
state.generation += 1
|
|
697
|
+
|
|
698
|
+
# Cache cost_matrix mean as tensor (defer .item() sync until needed)
|
|
699
|
+
_cost_mean_tensor = cost_matrix.mean()
|
|
700
|
+
|
|
701
|
+
# 10. Adaptive radius (use model loss, not OT regularized cost)
|
|
702
|
+
# Single GPU-CPU sync point for cost mean
|
|
703
|
+
_cost_mean = _cost_mean_tensor.item()
|
|
704
|
+
if opt.use_adaptive_radius:
|
|
705
|
+
rm, sc, pl = update_adaptive_radius(
|
|
706
|
+
_cost_mean,
|
|
707
|
+
state.prev_loss,
|
|
708
|
+
state.stagnation_count,
|
|
709
|
+
state.radius_multiplier,
|
|
710
|
+
stagnation_threshold=opt.stagnation_threshold,
|
|
711
|
+
stagnation_patience=opt.stagnation_patience,
|
|
712
|
+
radius_increase=opt.radius_increase,
|
|
713
|
+
radius_decrease=opt.radius_decrease,
|
|
714
|
+
radius_min=opt.radius_min,
|
|
715
|
+
radius_max=opt.radius_max,
|
|
716
|
+
)
|
|
717
|
+
state.radius_multiplier = rm
|
|
718
|
+
state.stagnation_count = sc
|
|
719
|
+
state.prev_loss = pl
|
|
720
|
+
|
|
721
|
+
# NaN-safe state update - revert X, velocity, and duals if NaN after projection
|
|
722
|
+
_nan_reverted = False
|
|
723
|
+
if not torch.isfinite(state.X).all():
|
|
724
|
+
state.X = X.clone()
|
|
725
|
+
# Reset velocity to prevent NaN propagation through momentum
|
|
726
|
+
if opt.use_momentum and state.velocity is not None:
|
|
727
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
728
|
+
_nan_reverted = True
|
|
729
|
+
|
|
730
|
+
# Clear biased rotation descent direction and Newton direction on NaN revert
|
|
731
|
+
if _nan_reverted and opt.biased_rotation:
|
|
732
|
+
opt._prev_descent_direction = None
|
|
733
|
+
opt._prev_descent_direction_finite = False
|
|
734
|
+
if _nan_reverted:
|
|
735
|
+
opt._newton_direction = None
|
|
736
|
+
|
|
737
|
+
# Capture transport direction for amortized OT (after NaN check)
|
|
738
|
+
if opt.amortize_steps > 1:
|
|
739
|
+
if _nan_reverted:
|
|
740
|
+
opt._transport_direction = None # NaN step, no valid direction
|
|
741
|
+
opt._transport_direction_ema = None
|
|
742
|
+
else:
|
|
743
|
+
# Pure OT step from X_bary, not state.X: momentum and Newton are
|
|
744
|
+
# applied separately, so the coasting direction must not re-carry them.
|
|
745
|
+
raw_direction = (X_bary - X).detach()
|
|
746
|
+
opt._transport_direction = raw_direction
|
|
747
|
+
# EMA blend: smooth transport direction across OT steps
|
|
748
|
+
alpha = opt.amortize_ema
|
|
749
|
+
if opt._transport_direction_ema is None:
|
|
750
|
+
opt._transport_direction_ema = raw_direction
|
|
751
|
+
else:
|
|
752
|
+
opt._transport_direction_ema = alpha * opt._transport_direction_ema + (1.0 - alpha) * raw_direction
|
|
753
|
+
|
|
754
|
+
# NB: trust-region update happens at the start of the next call to
|
|
755
|
+
# ``step`` (deferred), where we have a real post-step pre-OT measurement.
|
|
756
|
+
|
|
757
|
+
# 11. Update diagnostics (defer GPU-CPU sync for displacement)
|
|
758
|
+
per_particle_disp_sqnorms = torch.sum((state.X - X) ** 2, dim=-1) # (P,)
|
|
759
|
+
disp_sqnorm_tensor = torch.mean(per_particle_disp_sqnorms)
|
|
760
|
+
state.costs.append(_cost_mean)
|
|
761
|
+
state.linear_convergence.append(ot_result.converged)
|
|
762
|
+
state.displacement_sqnorms.append(disp_sqnorm_tensor.item())
|
|
763
|
+
state.iteration_count += 1
|
|
764
|
+
# Track OT-step costs separately for adaptive_num_probe (excludes momentum steps)
|
|
765
|
+
opt._ot_step_costs.append(_cost_mean)
|
|
766
|
+
|
|
767
|
+
# 11-AP. Adaptive probes: store per-particle displacement and cost matrix
|
|
768
|
+
# for next step's stagnation detection and cost-row reuse
|
|
769
|
+
if opt._adaptive_probes:
|
|
770
|
+
opt._prev_displacement_sqnorms = per_particle_disp_sqnorms.detach()
|
|
771
|
+
opt._prev_cost_matrix = raw_cost_matrix.detach()
|
|
772
|
+
opt._prev_rot_mats = rot_mats.detach()
|
|
773
|
+
opt._prev_k_eff = K_eff
|
|
774
|
+
opt._prev_step_r = step_r
|
|
775
|
+
# 11-MF. Multi-fidelity screening: store cost matrix for next step's
|
|
776
|
+
# direction contrast analysis (independent of adaptive probes)
|
|
777
|
+
if opt.multifidelity_screen and not opt._adaptive_probes:
|
|
778
|
+
opt._prev_cost_matrix = raw_cost_matrix.detach()
|
|
779
|
+
opt._prev_k_eff = K_eff
|
|
780
|
+
opt._prev_step_r = step_r
|
|
781
|
+
# Save duals for warm-starting; reset if the step reverted on NaN or a
|
|
782
|
+
# post-solve refinement moved the particles (duals now encode old positions).
|
|
783
|
+
if _nan_reverted or _duals_invalidated:
|
|
784
|
+
state.f = None
|
|
785
|
+
state.g = None
|
|
786
|
+
# Also clear momentum history - can't extrapolate from invalid state
|
|
787
|
+
if opt._dual_momentum_beta > 0.0:
|
|
788
|
+
state.prev_prev_f = None
|
|
789
|
+
state.prev_prev_g = None
|
|
790
|
+
else:
|
|
791
|
+
# Dual momentum: save previous duals before overwriting
|
|
792
|
+
if opt._dual_momentum_beta > 0.0:
|
|
793
|
+
state.prev_prev_f = state.f.clone() if state.f is not None else None
|
|
794
|
+
state.prev_prev_g = state.g.clone() if state.g is not None else None
|
|
795
|
+
state.f = ot_result.f.detach() if ot_result.f is not None else None
|
|
796
|
+
state.g = ot_result.g.detach() if ot_result.g is not None else None
|
|
797
|
+
state.epsilon = current_eps
|
|
798
|
+
|
|
799
|
+
# 11a. Adaptive subspace: displacement tracking, absorb, and rotation
|
|
800
|
+
if opt._adaptive and isinstance(opt.subspace, AdaptiveSubspace):
|
|
801
|
+
adaptive_sub = opt.subspace
|
|
802
|
+
|
|
803
|
+
# 1. Compute displacement in subspace coords
|
|
804
|
+
# The displacement is the change in the flattened subspace coordinate
|
|
805
|
+
# vector after the barycentric projection + momentum update.
|
|
806
|
+
post_step_sub_coords = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
|
|
807
|
+
displacement = post_step_sub_coords - _pre_step_sub_coords
|
|
808
|
+
|
|
809
|
+
# 2. Update displacement history (rolling buffer)
|
|
810
|
+
idx = state.displacement_history_idx
|
|
811
|
+
state.displacement_history[idx] = displacement
|
|
812
|
+
state.displacement_history_idx = (idx + 1) % adaptive_sub.displacement_history_size
|
|
813
|
+
state.displacement_history_count = min(
|
|
814
|
+
state.displacement_history_count + 1,
|
|
815
|
+
adaptive_sub.displacement_history_size,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# 3. Check for absorb trigger
|
|
819
|
+
should_absorb = adaptive_sub.should_absorb(
|
|
820
|
+
state.stagnation_count,
|
|
821
|
+
state.iteration_count, # already incremented above
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
if should_absorb:
|
|
825
|
+
# Absorb: fold perturbation into base, zero coords, new random basis.
|
|
826
|
+
full_flat_sub = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
|
|
827
|
+
new_base, _zeroed = adaptive_sub.absorb(
|
|
828
|
+
state.projection,
|
|
829
|
+
state.base_params,
|
|
830
|
+
full_flat_sub,
|
|
831
|
+
)
|
|
832
|
+
state.base_params = new_base
|
|
833
|
+
# Zero the subspace coordinates rather than re-projecting onto
|
|
834
|
+
# the new basis. The new and old bases are largely uncorrelated
|
|
835
|
+
# after a random redraw, so re-projection adds complexity for
|
|
836
|
+
# little benefit; the next OT solve will discover a fresh
|
|
837
|
+
# descent direction.
|
|
838
|
+
state.X = torch.zeros_like(state.X)
|
|
839
|
+
# New random projection
|
|
840
|
+
# Sparse projection: create new SparseRandomProjection with fresh seed
|
|
841
|
+
from .projection import SparseRandomProjection
|
|
842
|
+
|
|
843
|
+
if isinstance(state.projection, SparseRandomProjection):
|
|
844
|
+
# Increment seed based on absorb count for fresh random basis
|
|
845
|
+
new_seed = state.projection.seed + state.absorb_count + 1000
|
|
846
|
+
state.projection = SparseRandomProjection(
|
|
847
|
+
full_dim=state.projection.full_dim,
|
|
848
|
+
subspace_dim=state.projection.subspace_dim,
|
|
849
|
+
seed=new_seed,
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
state.projection = adaptive_sub.init_projection(
|
|
853
|
+
generator=opt._generator,
|
|
854
|
+
device=state.X.device,
|
|
855
|
+
dtype=state.X.dtype,
|
|
856
|
+
)
|
|
857
|
+
# Reset displacement history
|
|
858
|
+
state.displacement_history.zero_()
|
|
859
|
+
state.displacement_history_idx = 0
|
|
860
|
+
state.displacement_history_count = 0
|
|
861
|
+
# Reset duals (cost landscape changed)
|
|
862
|
+
state.f = None
|
|
863
|
+
state.g = None
|
|
864
|
+
state.prev_prev_f = None
|
|
865
|
+
state.prev_prev_g = None
|
|
866
|
+
# Invalidate EMA transport direction (cost geometry changed)
|
|
867
|
+
opt._transport_direction_ema = None
|
|
868
|
+
opt._transport_direction = None
|
|
869
|
+
# Increment absorb count
|
|
870
|
+
state.absorb_count += 1
|
|
871
|
+
# Invalidate cached cost/probe state (cost landscape changed after absorb)
|
|
872
|
+
opt._invalidate_reuse_cache()
|
|
873
|
+
opt._newton_direction = None
|
|
874
|
+
opt._prev_descent_direction = None
|
|
875
|
+
opt._prev_descent_direction_finite = False
|
|
876
|
+
# Momentum velocity lives in the pre-absorb basis; zero it.
|
|
877
|
+
if opt.use_momentum and state.velocity is not None:
|
|
878
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
879
|
+
# CMA-ES: Reset evolution paths and covariance after absorb
|
|
880
|
+
if opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa):
|
|
881
|
+
state.p_c = torch.zeros_like(state.p_c)
|
|
882
|
+
state.p_sigma = torch.zeros_like(state.p_sigma)
|
|
883
|
+
state.C_diag = torch.ones_like(state.C_diag)
|
|
884
|
+
state.sigma = 1.0
|
|
885
|
+
# Keep generation counter (don't reset to preserve cumulation history)
|
|
886
|
+
else:
|
|
887
|
+
# 4. Rotate projection basis for next step
|
|
888
|
+
# Design decision: rotate EVERY step (not every N steps) because the
|
|
889
|
+
# OT solve already provides a natural information-extraction per step,
|
|
890
|
+
# and rotating ensures maximum exploration of the full parameter space.
|
|
891
|
+
# Note: for large full_dim the basis QR/SVD dominates the step and far
|
|
892
|
+
# exceeds the tiny-V Sinkhorn solve, so it runs on the GPU in fp32.
|
|
893
|
+
|
|
894
|
+
# Sparse projection: use seed increment instead of QR rotation
|
|
895
|
+
from .projection import SparseRandomProjection
|
|
896
|
+
|
|
897
|
+
if isinstance(state.projection, SparseRandomProjection):
|
|
898
|
+
# Sparse projection doesn't support QR rotation; increment seed
|
|
899
|
+
new_seed = state.projection.seed + state.iteration_count
|
|
900
|
+
state.projection = SparseRandomProjection(
|
|
901
|
+
full_dim=state.projection.full_dim,
|
|
902
|
+
subspace_dim=state.projection.subspace_dim,
|
|
903
|
+
seed=new_seed,
|
|
904
|
+
)
|
|
905
|
+
else:
|
|
906
|
+
# Dense projection: use existing displacement-based rotation
|
|
907
|
+
hist = (
|
|
908
|
+
state.displacement_history[: state.displacement_history_count]
|
|
909
|
+
if state.displacement_history_count > 0
|
|
910
|
+
else None
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Pass OT info for ot_bias mode
|
|
914
|
+
ot_kwargs = {}
|
|
915
|
+
if hasattr(adaptive_sub, "rotation_mode") and adaptive_sub.rotation_mode == "ot_bias":
|
|
916
|
+
ot_kwargs = {
|
|
917
|
+
"transport_matrix": ot_result.matrix,
|
|
918
|
+
"X_vertices": _X_vertices_for_ot_bias,
|
|
919
|
+
"X_current": _X_pre_barycentric,
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
state.projection = adaptive_sub.rotate(
|
|
923
|
+
state.projection,
|
|
924
|
+
step=state.iteration_count,
|
|
925
|
+
total_steps=opt.max_iterations,
|
|
926
|
+
displacement_history=hist,
|
|
927
|
+
generator=opt._generator,
|
|
928
|
+
**ot_kwargs,
|
|
929
|
+
)
|
|
930
|
+
# Reset duals after rotation (cost geometry changed)
|
|
931
|
+
state.f = None
|
|
932
|
+
state.g = None
|
|
933
|
+
state.prev_prev_f = None
|
|
934
|
+
state.prev_prev_g = None
|
|
935
|
+
# Invalidate EMA transport direction and reuse cache (geometry changed)
|
|
936
|
+
opt._transport_direction_ema = None
|
|
937
|
+
opt._transport_direction = None
|
|
938
|
+
opt._invalidate_reuse_cache()
|
|
939
|
+
|
|
940
|
+
# 11a-2. HybridSubspace: displacement tracking, absorb, and rotation
|
|
941
|
+
# Similar to AdaptiveSubspace but uses per-layer projections dict
|
|
942
|
+
if opt._hybrid and isinstance(opt.subspace, HybridSubspace):
|
|
943
|
+
hybrid_sub = opt.subspace
|
|
944
|
+
|
|
945
|
+
# 1. Compute displacement in subspace coords
|
|
946
|
+
post_step_sub_coords = state.X.reshape(-1)[: hybrid_sub.subspace_dim]
|
|
947
|
+
displacement = post_step_sub_coords - _pre_step_sub_coords
|
|
948
|
+
|
|
949
|
+
# 2. Update displacement history (rolling buffer)
|
|
950
|
+
idx = state.displacement_history_idx
|
|
951
|
+
state.displacement_history[idx] = displacement
|
|
952
|
+
state.displacement_history_idx = (idx + 1) % hybrid_sub.displacement_history_size
|
|
953
|
+
state.displacement_history_count = min(
|
|
954
|
+
state.displacement_history_count + 1,
|
|
955
|
+
hybrid_sub.displacement_history_size,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# 3. Check for absorb trigger
|
|
959
|
+
should_absorb = hybrid_sub.should_absorb(
|
|
960
|
+
state.stagnation_count,
|
|
961
|
+
state.iteration_count,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
if should_absorb:
|
|
965
|
+
# Absorb: fold perturbation into base, zero coords, new projections
|
|
966
|
+
full_flat_sub = state.X.reshape(-1)[: hybrid_sub.subspace_dim]
|
|
967
|
+
new_base, _zeroed = hybrid_sub.absorb(
|
|
968
|
+
state.hybrid_projections,
|
|
969
|
+
state.base_params,
|
|
970
|
+
full_flat_sub,
|
|
971
|
+
)
|
|
972
|
+
state.base_params = new_base
|
|
973
|
+
# Reset subspace coordinates to zero
|
|
974
|
+
state.X = torch.zeros_like(state.X)
|
|
975
|
+
# Regenerate ALL per-layer projections
|
|
976
|
+
state.hybrid_projections = hybrid_sub.init_projections(
|
|
977
|
+
state.X.device,
|
|
978
|
+
state.X.dtype,
|
|
979
|
+
)
|
|
980
|
+
# Reset displacement history
|
|
981
|
+
state.displacement_history.zero_()
|
|
982
|
+
state.displacement_history_idx = 0
|
|
983
|
+
state.displacement_history_count = 0
|
|
984
|
+
# Reset duals (cost landscape changed)
|
|
985
|
+
state.f = None
|
|
986
|
+
state.g = None
|
|
987
|
+
state.prev_prev_f = None
|
|
988
|
+
state.prev_prev_g = None
|
|
989
|
+
# Invalidate EMA transport direction (cost geometry changed)
|
|
990
|
+
opt._transport_direction_ema = None
|
|
991
|
+
opt._transport_direction = None
|
|
992
|
+
# Increment absorb count
|
|
993
|
+
state.absorb_count += 1
|
|
994
|
+
# Invalidate cached cost/probe state (cost landscape changed after absorb)
|
|
995
|
+
opt._invalidate_reuse_cache()
|
|
996
|
+
opt._newton_direction = None
|
|
997
|
+
opt._prev_descent_direction = None
|
|
998
|
+
opt._prev_descent_direction_finite = False
|
|
999
|
+
# Momentum velocity lives in the pre-absorb basis; zero it.
|
|
1000
|
+
if opt.use_momentum and state.velocity is not None:
|
|
1001
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
1002
|
+
else:
|
|
1003
|
+
# 4. Rotate all per-layer projections for next step
|
|
1004
|
+
hist = (
|
|
1005
|
+
state.displacement_history[: state.displacement_history_count]
|
|
1006
|
+
if state.displacement_history_count > 0
|
|
1007
|
+
else None
|
|
1008
|
+
)
|
|
1009
|
+
new_projections = hybrid_sub.rotate_all(
|
|
1010
|
+
state.hybrid_projections,
|
|
1011
|
+
step=state.iteration_count,
|
|
1012
|
+
total_steps=opt.max_iterations,
|
|
1013
|
+
displacement_history=hist,
|
|
1014
|
+
)
|
|
1015
|
+
# Only act when projections changed (a rotation happened). With the
|
|
1016
|
+
# default rotation_interval=0 rotate_all returns the same dict, so the
|
|
1017
|
+
# block_diag rebuild below is skipped: it is a full, wasteful rebuild.
|
|
1018
|
+
if new_projections is not state.hybrid_projections:
|
|
1019
|
+
state.f = None
|
|
1020
|
+
state.g = None
|
|
1021
|
+
state.prev_prev_f = None
|
|
1022
|
+
state.prev_prev_g = None
|
|
1023
|
+
# Invalidate EMA transport direction and reuse cache (geometry changed)
|
|
1024
|
+
opt._transport_direction_ema = None
|
|
1025
|
+
opt._transport_direction = None
|
|
1026
|
+
opt._invalidate_reuse_cache()
|
|
1027
|
+
state.hybrid_projections = new_projections
|
|
1028
|
+
if hasattr(hybrid_sub, "build_fused_projection"):
|
|
1029
|
+
hybrid_sub.build_fused_projection(new_projections)
|
|
1030
|
+
|
|
1031
|
+
# 11b. Periodic absorb: fold perturbation into base, zero subspace
|
|
1032
|
+
# Only for non-adaptive/non-hybrid subspaces; their absorb handled separately.
|
|
1033
|
+
# Semantics: absorb AFTER every N steps (iteration_count already incremented above).
|
|
1034
|
+
# E.g., absorb_every=10 triggers at iteration_count=10,20,30,...
|
|
1035
|
+
if (
|
|
1036
|
+
not opt._adaptive
|
|
1037
|
+
and not opt._hybrid
|
|
1038
|
+
and not opt._cma_subspace
|
|
1039
|
+
and opt.subspace is not None
|
|
1040
|
+
and opt.absorb_every > 0
|
|
1041
|
+
and state.iteration_count % opt.absorb_every == 0
|
|
1042
|
+
):
|
|
1043
|
+
flat_sub = state.X.reshape(-1)[: state.subspace.subspace_dim]
|
|
1044
|
+
new_base, _zeroed = state.subspace.absorb(state.base_params, flat_sub)
|
|
1045
|
+
state.base_params = new_base
|
|
1046
|
+
# Zero the particle array (subspace coords reset)
|
|
1047
|
+
state.X = torch.zeros_like(state.X)
|
|
1048
|
+
# Reset dual potentials since cost landscape changed
|
|
1049
|
+
state.f = None
|
|
1050
|
+
state.g = None
|
|
1051
|
+
state.prev_prev_f = None
|
|
1052
|
+
state.prev_prev_g = None
|
|
1053
|
+
# Invalidate EMA transport direction and reuse cache (geometry changed)
|
|
1054
|
+
opt._transport_direction_ema = None
|
|
1055
|
+
opt._transport_direction = None
|
|
1056
|
+
opt._invalidate_reuse_cache()
|
|
1057
|
+
if opt.use_momentum and state.velocity is not None:
|
|
1058
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
1059
|
+
|
|
1060
|
+
# 11c. Rank schedule transition check
|
|
1061
|
+
if opt._rank_schedule is not None and opt.subspace is not None:
|
|
1062
|
+
current_rank = opt._rank_schedule.at(state.iteration_count)
|
|
1063
|
+
prev_rank = opt._rank_schedule.at(state.iteration_count - 1)
|
|
1064
|
+
if current_rank != prev_rank:
|
|
1065
|
+
opt._transition_rank(current_rank)
|
|
1066
|
+
|
|
1067
|
+
# 12. Write particles back to model
|
|
1068
|
+
opt._sync_model()
|
|
1069
|
+
|
|
1070
|
+
return _cost_mean
|