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
@@ -0,0 +1,762 @@
1
+ """Rotating orthogonal projection subspace.
2
+
3
+ :class:`AdaptiveSubspace` replaces :class:`LinearSubspace`'s fixed random
4
+ per-layer projection with a single *global* QR-orthogonal projection
5
+ ``P`` of shape ``(full_dim, subspace_dim)`` that can be re-drawn each
6
+ iteration, optionally biased toward productive directions.
7
+
8
+ Differences from :class:`LinearSubspace`:
9
+
10
+ - One projection matrix covers all parameters (cross-layer mixing).
11
+ - ``P`` is stored in ``SolverState.projection`` and passed in to every
12
+ method, so rotation does not require rebuilding the subspace object.
13
+ - Columns are QR-orthonormal (``P^T P = I``), so ``||P @ coords|| =
14
+ ||coords||``. There is no ``1/sqrt(N)`` dilation, so the right
15
+ ``step_radius`` is typically smaller than for :class:`LinearSubspace`.
16
+
17
+ Rotation modes:
18
+
19
+ - ``'random'`` - fresh QR-orthogonal basis every call.
20
+ - ``'displacement'`` - SVD of recent displacements keeps productive
21
+ directions; the SVD share grows linearly from ``svd_ratio_init`` to
22
+ ``svd_ratio_final`` over the schedule.
23
+ - ``'ot_bias'`` - biases a fraction of columns toward high-transport
24
+ directions extracted from the OT plan (falls back to random when the
25
+ full-dim layout doesn't match the particle layout).
26
+
27
+ ``absorb()`` folds the current subspace perturbation into the base
28
+ weights and zeros the subspace vector. Combined with rotation each
29
+ iteration explores a fresh subspace centered on the current best.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass
35
+ from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
36
+
37
+ import torch
38
+ import torch.nn as nn
39
+
40
+ if TYPE_CHECKING:
41
+ from .transform import ParamLayout
42
+
43
+
44
+ def _spawn_cpu_generator(generator: Optional[torch.Generator]) -> Optional[torch.Generator]:
45
+ """Mirror a non-CPU generator onto CPU, seeding from a fresh draw.
46
+
47
+ CPU tensors cannot use a CUDA generator, so CPU-side QR needs a CPU one.
48
+ Seeding it from initial_seed() reuses the same constant every call and
49
+ freezes every basis to be identical; drawing a new integer advances the
50
+ source generator so successive calls differ. Returns the generator unchanged
51
+ when it is already CPU or None.
52
+ """
53
+ if generator is None or generator.device.type == "cpu":
54
+ return generator
55
+ seed = int(torch.randint(0, 2**31 - 1, (1,), generator=generator, device=generator.device).item())
56
+ cpu_gen = torch.Generator(device="cpu")
57
+ cpu_gen.manual_seed(seed)
58
+ return cpu_gen
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class EntrySpec:
63
+ """Describes the mapping of a single parameter entry in the flat vector.
64
+
65
+ Attributes:
66
+ entry_key: state_dict key (e.g., "fc1.weight").
67
+ original_shape: Original parameter shape.
68
+ num_params: Total number of elements in this parameter.
69
+ flat_start: Start offset into the global flat vector.
70
+ flat_end: End offset into the global flat vector.
71
+ """
72
+
73
+ entry_key: str
74
+ original_shape: Tuple[int, ...]
75
+ num_params: int
76
+ flat_start: int
77
+ flat_end: int
78
+
79
+
80
+ @dataclass
81
+ class AdaptiveSubspace:
82
+ """Adaptive subspace compression with rotating orthogonal projection.
83
+
84
+ Stores static configuration only. The projection matrix P lives in
85
+ optimizer state (``SolverState.projection``) and is passed as an
86
+ argument to all methods.
87
+
88
+ Two rotation modes are supported:
89
+
90
+ - ``'random'``: Draws entirely new QR-orthogonalized basis each call
91
+ to ``rotate()``. Simple but effective -- equivalent to random search
92
+ in a new subspace each iteration.
93
+
94
+ - ``'displacement'``: Uses SVD of recent displacement history to retain
95
+ productive directions. The fraction of SVD-derived directions increases
96
+ linearly from ``svd_ratio_init`` to ``svd_ratio_final`` over optimization.
97
+
98
+ - ``'ot_bias'``: Biases projection toward high-transport directions from
99
+ the OT plan. The fraction of OT-derived directions is controlled by
100
+ ``ot_bias_ratio``. Falls back to random when OT info is not available.
101
+
102
+ Example::
103
+
104
+ from polystep import AdaptiveSubspace, ParamLayout
105
+
106
+ model = nn.Sequential(nn.Linear(100, 50), nn.Linear(50, 10))
107
+ sub = AdaptiveSubspace.auto_from_params(model)
108
+
109
+ # Initialize projection (store in optimizer state)
110
+ P = sub.init_projection(device='cuda')
111
+
112
+ # Each iteration: rotate, apply perturbation, absorb
113
+ P = sub.rotate(P, step=i, total_steps=100, displacement_history=disp)
114
+ perturbed_sd = sub.apply_perturbation(P, base_sd, coords)
115
+ base_sd, coords = sub.absorb(P, base_sd, coords)
116
+
117
+ Attributes:
118
+ full_dim: Total flattened parameter count.
119
+ subspace_dim: Number of subspace coordinates (rank).
120
+ compression_ratio: subspace_dim / full_dim.
121
+ rotation_mode: 'random', 'displacement', or 'ot_bias' (default 'displacement').
122
+ svd_ratio_init: Starting SVD ratio for displacement mode (default 0.0).
123
+ svd_ratio_final: Ending SVD ratio for displacement mode (default 0.5).
124
+ ot_bias_ratio: Fraction of subspace from OT directions in 'ot_bias' mode
125
+ (default 0.3).
126
+ displacement_history_size: Rolling window size for displacement
127
+ history (default 5).
128
+ absorb_mode: 'stagnation' or 'periodic' (default 'stagnation').
129
+ absorb_patience: Steps of stagnation before absorb (default 20).
130
+ absorb_interval: Periodic absorb interval; 0 = disabled (default 0).
131
+ """
132
+
133
+ full_dim: int
134
+ subspace_dim: int
135
+ compression_ratio: float = 0.0
136
+ rotation_mode: str = "displacement"
137
+ svd_ratio_init: float = 0.0
138
+ svd_ratio_final: float = 0.5
139
+ ot_bias_ratio: float = 0.3
140
+ displacement_history_size: int = 5
141
+ absorb_mode: str = "stagnation"
142
+ absorb_patience: int = 20
143
+ absorb_interval: int = 0
144
+ _entry_specs: Tuple[EntrySpec, ...] = ()
145
+
146
+ def __post_init__(self) -> None:
147
+ if self.compression_ratio == 0.0 and self.full_dim > 0:
148
+ object.__setattr__(self, "compression_ratio", self.subspace_dim / self.full_dim)
149
+
150
+ # ------------------------------------------------------------------
151
+ # Projection initialization
152
+ # ------------------------------------------------------------------
153
+
154
+ def init_projection(
155
+ self,
156
+ generator: Optional[torch.Generator] = None,
157
+ device: Optional[torch.device] = None,
158
+ dtype: Optional[torch.dtype] = None,
159
+ ) -> torch.Tensor:
160
+ """Generate initial random orthogonal projection matrix.
161
+
162
+ Creates P of shape ``(full_dim, subspace_dim)`` with orthonormal
163
+ columns via QR decomposition of a random Gaussian matrix. The sign
164
+ ambiguity of QR is resolved by making the diagonal of R positive.
165
+
166
+ Args:
167
+ generator: Optional torch.Generator for reproducibility.
168
+ device: Target device for the projection matrix. If None, uses CPU.
169
+ dtype: Optional dtype for projection matrix. If None, uses float32.
170
+ Use bfloat16 for mixed precision mode to reduce memory.
171
+
172
+ Returns:
173
+ Projection matrix P with shape ``(full_dim, subspace_dim)``
174
+ satisfying ``P.T @ P = I``.
175
+ """
176
+ # Default to float32 if not specified
177
+ projection_dtype = dtype if dtype is not None else torch.float32
178
+ projection_device = device if device is not None else "cpu"
179
+ return self._make_orthogonal_basis(
180
+ self.full_dim,
181
+ self.subspace_dim,
182
+ device=projection_device,
183
+ dtype=projection_dtype,
184
+ generator=generator,
185
+ )
186
+
187
+ @staticmethod
188
+ def _make_orthogonal_basis(
189
+ rows: int,
190
+ cols: int,
191
+ device: str | torch.device = "cpu",
192
+ dtype: torch.dtype = torch.float32,
193
+ generator: Optional[torch.Generator] = None,
194
+ ) -> torch.Tensor:
195
+ """Create an orthogonal matrix via QR with sign correction.
196
+
197
+ Args:
198
+ rows: Number of rows (full_dim).
199
+ cols: Number of columns (subspace_dim). Must be <= rows.
200
+ device: Target device.
201
+ dtype: Target dtype.
202
+ generator: Optional PRNG generator.
203
+
204
+ Returns:
205
+ Orthogonal matrix of shape (rows, cols).
206
+ """
207
+ # Generate Z on CPU in fp32 for reproducibility: a CUDA generator cannot
208
+ # drive a CPU tensor, so spawn a CPU generator seeded from it. The random
209
+ # stream is therefore identical on CPU and CUDA targets.
210
+ generator = _spawn_cpu_generator(generator)
211
+ Z = torch.randn(rows, cols, generator=generator, device="cpu", dtype=torch.float32)
212
+ # QR of the tall (rows, cols) matrix dominates the per-step cost. Run it
213
+ # on the GPU when the target is CUDA (measured several times faster than
214
+ # CPU QR for large full_dim); bf16 QR is unsupported, so decompose in
215
+ # fp32 and cast below.
216
+ target_device = torch.device(device)
217
+ if target_device.type == "cuda":
218
+ Z = Z.to(target_device)
219
+ P, R = torch.linalg.qr(Z)
220
+ # Fix sign ambiguity: positive diagonal in R (replace zeros with 1).
221
+ d = torch.sign(torch.diagonal(R))
222
+ d[d == 0] = 1.0
223
+ P = (P * d)[:, :cols] # slice in case QR returned a full square Q
224
+ if dtype != torch.float32:
225
+ P = P.to(dtype=dtype)
226
+ if P.device != target_device:
227
+ P = P.to(device=target_device)
228
+ return P
229
+
230
+ # ------------------------------------------------------------------
231
+ # Rotation
232
+ # ------------------------------------------------------------------
233
+
234
+ @torch.inference_mode()
235
+ def rotate(
236
+ self,
237
+ projection: torch.Tensor,
238
+ step: int,
239
+ total_steps: int,
240
+ displacement_history: Optional[torch.Tensor] = None,
241
+ generator: Optional[torch.Generator] = None,
242
+ # OT-bias mode inputs
243
+ transport_matrix: Optional[torch.Tensor] = None,
244
+ X_vertices: Optional[torch.Tensor] = None,
245
+ X_current: Optional[torch.Tensor] = None,
246
+ ) -> torch.Tensor:
247
+ """Rotate the projection basis according to the configured mode.
248
+
249
+ For ``'random'`` mode, draws an entirely new QR-orthogonalized basis.
250
+ For ``'displacement'`` mode, uses SVD of the displacement history to
251
+ keep productive directions and fills the remainder with random.
252
+ For ``'ot_bias'`` mode, uses high-transport directions from the OT
253
+ plan to bias a fraction of the projection basis.
254
+
255
+ Args:
256
+ projection: Current projection matrix P of shape
257
+ ``(full_dim, subspace_dim)``.
258
+ step: Current optimization step (0-indexed).
259
+ total_steps: Total number of optimization steps.
260
+ displacement_history: Optional tensor of shape
261
+ ``(history_len, subspace_dim)`` with recent displacement
262
+ vectors in subspace coordinates. Required for displacement
263
+ mode; if None, falls back to random rotation.
264
+ generator: Optional torch.Generator for reproducibility.
265
+ transport_matrix: OT transport plan, shape (num_particles, num_vertices).
266
+ Required for ot_bias mode; if None, falls back to random.
267
+ X_vertices: Polytope vertex positions for each particle,
268
+ shape (num_particles, num_vertices, particle_dim).
269
+ Required for ot_bias mode.
270
+ X_current: Current particle positions, shape (num_particles, particle_dim).
271
+ Required for ot_bias mode.
272
+
273
+ Returns:
274
+ New projection matrix P_new of shape ``(full_dim, subspace_dim)``
275
+ with orthonormal columns.
276
+ """
277
+ device = projection.device
278
+ dtype = projection.dtype
279
+
280
+ # Handle ot_bias mode
281
+ if self.rotation_mode == "ot_bias":
282
+ has_ot_info = transport_matrix is not None and X_vertices is not None and X_current is not None
283
+ if has_ot_info:
284
+ return self._rotate_ot_bias(
285
+ transport_matrix,
286
+ X_vertices,
287
+ X_current,
288
+ device,
289
+ dtype,
290
+ generator,
291
+ )
292
+ else:
293
+ # Fallback to random when OT info not available
294
+ return self._rotate_random(device, dtype, generator)
295
+
296
+ # Fall back to random if displacement mode lacks history
297
+ use_random = (
298
+ self.rotation_mode == "random" or displacement_history is None or displacement_history.shape[0] == 0
299
+ )
300
+
301
+ if not use_random:
302
+ # Check if displacement history has meaningful magnitude and is finite
303
+ if not torch.isfinite(displacement_history).all():
304
+ use_random = True
305
+ else:
306
+ disp_norm_sq = (displacement_history * displacement_history).sum().item()
307
+ if disp_norm_sq < 1e-20:
308
+ use_random = True
309
+
310
+ if use_random:
311
+ return self._rotate_random(device, dtype, generator)
312
+ else:
313
+ svd_ratio = self.get_svd_ratio(step, total_steps)
314
+ return self._rotate_displacement(
315
+ projection,
316
+ displacement_history,
317
+ svd_ratio,
318
+ device,
319
+ dtype,
320
+ generator,
321
+ )
322
+
323
+ @torch.inference_mode()
324
+ def _rotate_random(
325
+ self,
326
+ device: str | torch.device,
327
+ dtype: torch.dtype,
328
+ generator: Optional[torch.Generator] = None,
329
+ ) -> torch.Tensor:
330
+ """Draw entirely new QR-orthogonalized basis.
331
+
332
+ Args:
333
+ device: Target device.
334
+ dtype: Target dtype.
335
+ generator: Optional PRNG generator.
336
+
337
+ Returns:
338
+ New orthogonal projection of shape (full_dim, subspace_dim).
339
+ """
340
+ return self._make_orthogonal_basis(
341
+ self.full_dim,
342
+ self.subspace_dim,
343
+ device=device,
344
+ dtype=dtype,
345
+ generator=generator,
346
+ )
347
+
348
+ @torch.inference_mode()
349
+ def _rotate_displacement(
350
+ self,
351
+ projection: torch.Tensor,
352
+ displacement_history: torch.Tensor,
353
+ svd_ratio: float,
354
+ device: str | torch.device,
355
+ dtype: torch.dtype,
356
+ generator: Optional[torch.Generator] = None,
357
+ ) -> torch.Tensor:
358
+ """Rotate basis using SVD of displacement history.
359
+
360
+ Projects displacement history to full parameter space, computes SVD
361
+ to find productive directions, keeps the top ``k_svd`` singular
362
+ vectors, and fills the remaining ``k_random`` directions with random
363
+ vectors. The combined matrix is QR-orthogonalized.
364
+
365
+ Args:
366
+ projection: Current projection P of shape (full_dim, subspace_dim).
367
+ displacement_history: Shape (history_len, subspace_dim).
368
+ svd_ratio: Fraction of subspace_dim to fill with SVD directions.
369
+ device: Target device.
370
+ dtype: Target dtype.
371
+ generator: Optional PRNG generator.
372
+
373
+ Returns:
374
+ New orthogonal projection of shape (full_dim, subspace_dim).
375
+ """
376
+ k_svd = max(1, int(svd_ratio * self.subspace_dim))
377
+ k_random = self.subspace_dim - k_svd
378
+
379
+ # Project displacement history to full parameter space
380
+ # displacement_history: (history_len, subspace_dim)
381
+ # projection: (full_dim, subspace_dim)
382
+ # D_full: (full_dim, history_len)
383
+ D_full = projection @ displacement_history.T
384
+
385
+ # Guard against non-finite values from numerical issues
386
+ if not torch.isfinite(D_full).all():
387
+ return self._rotate_random(device, dtype, generator)
388
+
389
+ # SVD/QR reject bf16 on CPU; run the decomposition in fp32 and cast back.
390
+ compute_dtype = torch.float32 if dtype == torch.bfloat16 else dtype
391
+ D_full = D_full.to(compute_dtype)
392
+
393
+ # SVD of the full-space displacement matrix
394
+ if k_svd < min(D_full.shape) // 2 and min(D_full.shape) > 6:
395
+ # Randomized SVD: faster when k_svd << rank
396
+ U_top, S_top, V_top = torch.pca_lowrank(D_full, q=k_svd, niter=2)
397
+ else:
398
+ U, S, Vh = torch.linalg.svd(D_full, full_matrices=False)
399
+ k_svd = min(k_svd, U.shape[1])
400
+ U_top = U[:, :k_svd]
401
+ k_random = self.subspace_dim - k_svd
402
+
403
+ # Generate random directions for the remainder
404
+ gen_device = "cpu"
405
+ # Handle generator device mismatch: create CPU generator for reproducibility
406
+ gen_to_use = _spawn_cpu_generator(generator)
407
+ Z_random = torch.randn(
408
+ self.full_dim,
409
+ k_random,
410
+ generator=gen_to_use,
411
+ device=gen_device,
412
+ dtype=compute_dtype,
413
+ )
414
+ if str(device) != gen_device:
415
+ Z_random = Z_random.to(device=device)
416
+ if U_top.device != Z_random.device:
417
+ U_top = U_top.to(device=Z_random.device)
418
+
419
+ # Concatenate SVD directions + random, then QR-orthogonalize
420
+ combined = torch.cat([U_top, Z_random], dim=1)
421
+ P_new, R = torch.linalg.qr(combined)
422
+ # Fix sign ambiguity
423
+ d = torch.sign(torch.diagonal(R))
424
+ d[d == 0] = 1.0
425
+ P_new = P_new * d
426
+ P_new = P_new[:, : self.subspace_dim]
427
+ if P_new.dtype != dtype:
428
+ P_new = P_new.to(dtype)
429
+
430
+ if P_new.device != device:
431
+ P_new = P_new.to(device=device)
432
+
433
+ return P_new
434
+
435
+ @torch.inference_mode()
436
+ def _rotate_ot_bias(
437
+ self,
438
+ transport_matrix: torch.Tensor,
439
+ X_vertices: torch.Tensor,
440
+ X_current: torch.Tensor,
441
+ device: str | torch.device,
442
+ dtype: torch.dtype,
443
+ generator: Optional[torch.Generator] = None,
444
+ ) -> torch.Tensor:
445
+ """Rotate basis using OT-informed high-transport directions.
446
+
447
+ Extracts directions where particles moved the most mass according to
448
+ the transport plan, projects these particle-space directions to full
449
+ parameter space (by tiling to match full_dim), then combines with
450
+ random directions via QR orthogonalization.
451
+
452
+ The fraction of directions from OT bias is controlled by ot_bias_ratio.
453
+
454
+ Args:
455
+ transport_matrix: OT transport plan, shape (num_particles, num_vertices).
456
+ X_vertices: Polytope vertices, shape (num_particles, num_vertices, particle_dim).
457
+ X_current: Current particles, shape (num_particles, particle_dim).
458
+ device: Target device.
459
+ dtype: Target dtype.
460
+ generator: Optional PRNG generator.
461
+
462
+ Returns:
463
+ New orthogonal projection of shape (full_dim, subspace_dim).
464
+ """
465
+ from .cma import compute_ot_bias_directions
466
+
467
+ # Number of directions to allocate to OT bias vs random.
468
+ k_ot = max(1, int(self.ot_bias_ratio * self.subspace_dim))
469
+
470
+ # Get high-transport directions in particle space.
471
+ ot_dirs = compute_ot_bias_directions(
472
+ transport_matrix,
473
+ X_vertices,
474
+ X_current,
475
+ top_k=k_ot,
476
+ )
477
+ # ot_dirs: (k_ot_actual, particle_dim)
478
+ k_ot_actual = ot_dirs.shape[0]
479
+
480
+ # Lifting particle-space directions to ``full_dim`` only makes sense
481
+ # when the full vector is a concatenation of equal-size particle
482
+ # slots (full_dim == num_particles * particle_dim). For real
483
+ # parameter layouts ``full_dim`` is the total number of trainable
484
+ # parameters; tiling would map an OT direction to arbitrary layer
485
+ # weights and lose its meaning. Fall back to pure random in that
486
+ # case.
487
+ num_particles = X_current.shape[0]
488
+ particle_dim = ot_dirs.shape[1]
489
+ if num_particles * particle_dim == self.full_dim:
490
+ ot_dirs_full = ot_dirs.unsqueeze(1).expand(-1, num_particles, -1)
491
+ ot_dirs_full = ot_dirs_full.reshape(k_ot_actual, -1)
492
+ ot_cols = ot_dirs_full.T # (full_dim, k_ot_actual)
493
+ else:
494
+ ot_cols = None
495
+ k_ot_actual = 0
496
+ k_random = self.subspace_dim - k_ot_actual
497
+
498
+ # Generate random directions for the remainder
499
+ gen_device = "cpu"
500
+ # Handle generator device mismatch: create CPU generator for reproducibility
501
+ gen_to_use = _spawn_cpu_generator(generator)
502
+ # QR rejects bf16 on CPU; build in fp32 and cast the result back.
503
+ compute_dtype = torch.float32 if dtype == torch.bfloat16 else dtype
504
+ Z_random = torch.randn(
505
+ self.full_dim,
506
+ k_random,
507
+ generator=gen_to_use,
508
+ device=gen_device,
509
+ dtype=compute_dtype,
510
+ )
511
+ if str(device) != gen_device:
512
+ Z_random = Z_random.to(device=device)
513
+ if ot_cols is not None:
514
+ ot_cols = ot_cols.to(device=Z_random.device, dtype=compute_dtype)
515
+ combined = torch.cat([ot_cols, Z_random], dim=1)
516
+ else:
517
+ combined = Z_random
518
+ P_new, R = torch.linalg.qr(combined)
519
+ # Fix sign ambiguity
520
+ d = torch.sign(torch.diagonal(R))
521
+ d[d == 0] = 1.0
522
+ P_new = P_new * d
523
+ P_new = P_new[:, : self.subspace_dim]
524
+ if P_new.dtype != dtype:
525
+ P_new = P_new.to(dtype)
526
+
527
+ if P_new.device != device:
528
+ P_new = P_new.to(device=device)
529
+
530
+ return P_new
531
+
532
+ def get_svd_ratio(self, step: int, total_steps: int) -> float:
533
+ """Compute SVD ratio at the given step via linear interpolation.
534
+
535
+ The ratio starts at ``svd_ratio_init`` (step 0) and linearly
536
+ increases to ``svd_ratio_final`` (step = total_steps).
537
+
538
+ Args:
539
+ step: Current optimization step.
540
+ total_steps: Total number of steps.
541
+
542
+ Returns:
543
+ SVD ratio in [svd_ratio_init, svd_ratio_final].
544
+ """
545
+ progress = min(1.0, step / max(1, total_steps))
546
+ return self.svd_ratio_init + progress * (self.svd_ratio_final - self.svd_ratio_init)
547
+
548
+ # ------------------------------------------------------------------
549
+ # Core interface methods (match LinearSubspace contract)
550
+ # ------------------------------------------------------------------
551
+
552
+ def apply_perturbation(
553
+ self,
554
+ projection,
555
+ base_sd: Dict[str, torch.Tensor],
556
+ flat_subspace: torch.Tensor,
557
+ ) -> Dict[str, torch.Tensor]:
558
+ """Reconstruct full state_dict from base params + subspace coords.
559
+
560
+ Computes ``delta_flat = P @ coords`` in full parameter space, then
561
+ slices and reshapes per entry to reconstruct individual parameters.
562
+
563
+ Args:
564
+ projection: Projection matrix P of shape (full_dim, subspace_dim),
565
+ or SparseRandomProjection instance for sparse mode.
566
+ base_sd: Base state_dict with original parameter values.
567
+ flat_subspace: 1D subspace coordinate vector of shape (subspace_dim,).
568
+
569
+ Returns:
570
+ New state_dict with perturbed parameters.
571
+ """
572
+ # Handle sparse projection
573
+ from .projection import SparseRandomProjection
574
+
575
+ if isinstance(projection, SparseRandomProjection):
576
+ delta_flat = projection.project(flat_subspace) # (full_dim,)
577
+ else:
578
+ delta_flat = projection @ flat_subspace # (full_dim,)
579
+ result: Dict[str, torch.Tensor] = {}
580
+ for spec in self._entry_specs:
581
+ delta_chunk = delta_flat[spec.flat_start : spec.flat_end]
582
+ base = base_sd[spec.entry_key]
583
+ result[spec.entry_key] = base + delta_chunk.reshape(spec.original_shape)
584
+ return result
585
+
586
+ def reconstruct_batch(
587
+ self,
588
+ projection,
589
+ base_sd: Dict[str, torch.Tensor],
590
+ flat_subspace_batch: torch.Tensor,
591
+ ) -> Dict[str, torch.Tensor]:
592
+ """Vectorized reconstruction for N probe points.
593
+
594
+ Computes ``delta_batch = batch @ P.T`` to get (N, full_dim) deltas,
595
+ then slices and reshapes per entry.
596
+
597
+ Args:
598
+ projection: Projection matrix P of shape (full_dim, subspace_dim),
599
+ or SparseRandomProjection instance for sparse mode.
600
+ base_sd: Base state_dict with original parameter values.
601
+ flat_subspace_batch: 2D tensor of shape (N, subspace_dim).
602
+
603
+ Returns:
604
+ Dict ``{key: (N, *original_shape)}`` with batched perturbed params.
605
+ """
606
+ N = flat_subspace_batch.shape[0]
607
+ # Handle sparse projection
608
+ from .projection import SparseRandomProjection
609
+
610
+ if isinstance(projection, SparseRandomProjection):
611
+ # Sparse projection: use project() method for batch
612
+ delta_batch = projection.project(flat_subspace_batch) # (N, full_dim)
613
+ else:
614
+ # (N, subspace_dim) @ (subspace_dim, full_dim) -> (N, full_dim)
615
+ delta_batch = flat_subspace_batch @ projection.T # (N, full_dim)
616
+ result: Dict[str, torch.Tensor] = {}
617
+ for spec in self._entry_specs:
618
+ delta_chunk = delta_batch[:, spec.flat_start : spec.flat_end]
619
+ base = base_sd[spec.entry_key]
620
+ result[spec.entry_key] = base.unsqueeze(0) + delta_chunk.reshape(N, *spec.original_shape)
621
+ return result
622
+
623
+ def absorb(
624
+ self,
625
+ projection,
626
+ base_sd: Dict[str, torch.Tensor],
627
+ flat_subspace: torch.Tensor,
628
+ ) -> Tuple[Dict[str, torch.Tensor], torch.Tensor]:
629
+ """Fold subspace perturbation into base weights and zero the subspace.
630
+
631
+ This is the standard absorb operation: apply the current perturbation
632
+ to base weights, then return zeroed subspace coordinates. Combined
633
+ with rotation, this recenters the subspace around updated parameters.
634
+
635
+ Args:
636
+ projection: Projection matrix P of shape (full_dim, subspace_dim),
637
+ or SparseRandomProjection instance for sparse mode.
638
+ base_sd: Base state_dict to absorb perturbation into.
639
+ flat_subspace: Current subspace vector of shape (subspace_dim,).
640
+
641
+ Returns:
642
+ Tuple of (new_base_sd, zeroed_subspace_vector).
643
+ """
644
+ new_sd = self.apply_perturbation(projection, base_sd, flat_subspace)
645
+ return new_sd, torch.zeros_like(flat_subspace)
646
+
647
+ def should_absorb(self, stagnation_count: int, iteration: int) -> bool:
648
+ """Check whether an absorb-and-rotate should be triggered.
649
+
650
+ Args:
651
+ stagnation_count: Number of consecutive steps without improvement.
652
+ iteration: Current iteration number (0-indexed).
653
+
654
+ Returns:
655
+ True if absorb should be triggered based on the configured mode.
656
+ """
657
+ if self.absorb_mode == "stagnation":
658
+ return stagnation_count >= self.absorb_patience
659
+ elif self.absorb_mode == "periodic" and self.absorb_interval > 0:
660
+ return iteration > 0 and iteration % self.absorb_interval == 0
661
+ return False
662
+
663
+ # ------------------------------------------------------------------
664
+ # Factory methods
665
+ # ------------------------------------------------------------------
666
+
667
+ @classmethod
668
+ def auto_from_params(
669
+ cls,
670
+ model: nn.Module,
671
+ compression_target: float = 0.05,
672
+ min_rank: int = 64,
673
+ max_rank: int = 4096,
674
+ **kwargs,
675
+ ) -> AdaptiveSubspace:
676
+ """Create an AdaptiveSubspace from an nn.Module with auto rank.
677
+
678
+ Computes the total parameter count, selects a subspace rank based
679
+ on the compression target (clamped to [min_rank, max_rank]), and
680
+ builds entry specs from the model's ``ParamLayout``.
681
+
682
+ Args:
683
+ model: Any PyTorch module.
684
+ compression_target: Target ratio of subspace_dim / full_dim.
685
+ min_rank: Minimum subspace dimension.
686
+ max_rank: Maximum subspace dimension.
687
+ **kwargs: Additional keyword arguments passed to the constructor
688
+ (e.g., rotation_mode, svd_ratio_init, svd_ratio_final).
689
+
690
+ Returns:
691
+ AdaptiveSubspace configured for the model.
692
+ """
693
+ from .transform import ParamLayout
694
+
695
+ layout = ParamLayout.from_module(model)
696
+ full_dim = layout.total_params
697
+ subspace_dim = max(min_rank, min(max_rank, int(full_dim * compression_target)))
698
+ subspace_dim = min(subspace_dim, full_dim)
699
+
700
+ entry_specs = cls._build_entry_specs(layout)
701
+
702
+ return cls(
703
+ full_dim=full_dim,
704
+ subspace_dim=subspace_dim,
705
+ _entry_specs=tuple(entry_specs),
706
+ **kwargs,
707
+ )
708
+
709
+ @classmethod
710
+ def from_layout(
711
+ cls,
712
+ layout: "ParamLayout",
713
+ rank: int,
714
+ **kwargs,
715
+ ) -> AdaptiveSubspace:
716
+ """Create an AdaptiveSubspace from a ParamLayout with explicit rank.
717
+
718
+ Args:
719
+ layout: ParamLayout describing the model's parameter structure.
720
+ rank: Subspace dimension (will be clamped to full_dim).
721
+ **kwargs: Additional keyword arguments passed to the constructor.
722
+
723
+ Returns:
724
+ AdaptiveSubspace configured for the layout.
725
+ """
726
+ full_dim = layout.total_params
727
+ subspace_dim = min(rank, full_dim)
728
+
729
+ entry_specs = cls._build_entry_specs(layout)
730
+
731
+ return cls(
732
+ full_dim=full_dim,
733
+ subspace_dim=subspace_dim,
734
+ _entry_specs=tuple(entry_specs),
735
+ **kwargs,
736
+ )
737
+
738
+ @staticmethod
739
+ def _build_entry_specs(layout: "ParamLayout") -> List[EntrySpec]:
740
+ """Build EntrySpec list from a ParamLayout.
741
+
742
+ Maps each parameter entry to a contiguous slice of the global flat
743
+ vector of size full_dim (= layout.total_params).
744
+
745
+ Args:
746
+ layout: ParamLayout with entry metadata.
747
+
748
+ Returns:
749
+ List of EntrySpec for each parameter entry.
750
+ """
751
+ specs: List[EntrySpec] = []
752
+ for entry in layout.entries:
753
+ specs.append(
754
+ EntrySpec(
755
+ entry_key=entry.key,
756
+ original_shape=entry.shape,
757
+ num_params=entry.numel,
758
+ flat_start=entry.offset,
759
+ flat_end=entry.offset + entry.numel,
760
+ )
761
+ )
762
+ return specs