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/subspace.py ADDED
@@ -0,0 +1,694 @@
1
+ """Subspace compression for neural network parameters.
2
+
3
+ Reduces the OT problem from ``N`` parameters to a ``K``-dimensional
4
+ subspace (``K << N``) and projects back to full parameter space, dropping
5
+ the OT solver's memory from O(N^2) to O(K^2).
6
+
7
+ Two factorisations are provided:
8
+
9
+ - :class:`LowRankSubspace` - ``delta_W = B @ A``, bilinear in the subspace
10
+ coords. Near zero this bilinearity flattens the cost landscape and
11
+ often gives uniform OT transport; useful for scalability diagnostics.
12
+ - :class:`LinearSubspace` - ``delta_W = P @ coords`` with a fixed random
13
+ projection ``P``. Linear in the subspace coords, so probing
14
+ ``coords + alpha * direction`` produces a proportional cost change.
15
+ Recommended for training.
16
+
17
+ All factors/coords are packed into a single flat subspace vector with
18
+ per-layer offsets. That vector *is* the particle array for the OT solver
19
+ (reshaped to ``(num_particles, particle_dim)``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import zlib
26
+ from dataclasses import dataclass
27
+ from typing import Dict, Optional, Tuple, TYPE_CHECKING
28
+
29
+ import torch
30
+
31
+
32
+ def _stable_entry_seed(*parts: object) -> int:
33
+ """Hash a tuple of seed components to a 31-bit integer, deterministically
34
+ across processes (unlike Python's salted ``hash``).
35
+ """
36
+ key = "|".join(str(p) for p in parts).encode("utf-8")
37
+ return int(zlib.adler32(key)) & 0x7FFFFFFF
38
+
39
+
40
+ if TYPE_CHECKING:
41
+ from .transform import ParamLayout
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class FactorSpec:
46
+ """Describes the low-rank decomposition of a single parameter entry.
47
+
48
+ Attributes:
49
+ entry_key: ParamLayout entry key (e.g., "fc1.weight").
50
+ original_shape: Original parameter shape.
51
+ is_lowrank: True for 2D+ params (B@A compression), False for 1D (full).
52
+ b_shape: Shape of B factor (d_out, r), or () for 1D params.
53
+ a_shape: Shape of A factor (r, d_in), or () for 1D params.
54
+ flat_start: Start offset into the flat subspace vector.
55
+ flat_end: End offset into the flat subspace vector.
56
+ """
57
+
58
+ entry_key: str
59
+ original_shape: Tuple[int, ...]
60
+ is_lowrank: bool
61
+ b_shape: Tuple[int, ...]
62
+ a_shape: Tuple[int, ...]
63
+ flat_start: int
64
+ flat_end: int
65
+
66
+
67
+ @dataclass
68
+ class LowRankSubspace:
69
+ """Low-rank subspace compression for neural network parameters.
70
+
71
+ Packs per-layer B and A factors into a single flat subspace vector.
72
+ Supports both fixed-rank and auto-rank (per-layer) modes.
73
+
74
+ Attributes:
75
+ specs: Per-parameter decomposition specs.
76
+ rank: Fixed rank used (0 for auto mode with per-layer ranks).
77
+ subspace_dim: Total flat subspace vector size (sum of all factor elements).
78
+ compression_ratio: subspace_dim / total_params for diagnostics.
79
+ """
80
+
81
+ specs: Tuple[FactorSpec, ...]
82
+ rank: int
83
+ subspace_dim: int
84
+ compression_ratio: float
85
+
86
+ # ------------------------------------------------------------------
87
+ # Factory methods
88
+ # ------------------------------------------------------------------
89
+
90
+ @classmethod
91
+ def from_layout(cls, layout: ParamLayout, rank: int) -> LowRankSubspace:
92
+ """Create a LowRankSubspace with fixed rank from a ParamLayout.
93
+
94
+ For each 2D+ parameter entry, computes effective_rank = min(rank, d_in, d_out)
95
+ and creates B=(d_out, effective_rank), A=(effective_rank, d_in) factors.
96
+ 1D parameters are stored as full perturbations (no compression).
97
+
98
+ Args:
99
+ layout: ParamLayout describing the model's parameter structure.
100
+ rank: Fixed rank for all low-rank decompositions.
101
+
102
+ Returns:
103
+ LowRankSubspace with per-layer FactorSpecs and flat offsets.
104
+ """
105
+ specs = []
106
+ offset = 0
107
+
108
+ for entry in layout.entries:
109
+ shape = entry.shape
110
+ if len(shape) >= 2:
111
+ # 2D+ param: low-rank B@A decomposition
112
+ # Conv: (C_out, C_in, H, W) -> d_out=C_out, d_in=C_in*H*W
113
+ d_out = shape[0]
114
+ d_in = math.prod(shape[1:])
115
+ effective_rank = min(rank, d_in, d_out)
116
+ b_shape = (d_out, effective_rank)
117
+ a_shape = (effective_rank, d_in)
118
+ num_elements = d_out * effective_rank + effective_rank * d_in
119
+ specs.append(
120
+ FactorSpec(
121
+ entry_key=entry.key,
122
+ original_shape=shape,
123
+ is_lowrank=True,
124
+ b_shape=b_shape,
125
+ a_shape=a_shape,
126
+ flat_start=offset,
127
+ flat_end=offset + num_elements,
128
+ )
129
+ )
130
+ offset += num_elements
131
+ else:
132
+ # 1D param (bias, LayerNorm): full perturbation
133
+ num_elements = entry.numel
134
+ specs.append(
135
+ FactorSpec(
136
+ entry_key=entry.key,
137
+ original_shape=shape,
138
+ is_lowrank=False,
139
+ b_shape=(),
140
+ a_shape=(),
141
+ flat_start=offset,
142
+ flat_end=offset + num_elements,
143
+ )
144
+ )
145
+ offset += num_elements
146
+
147
+ total_params = layout.total_params
148
+ compression = offset / total_params if total_params > 0 else 0.0
149
+
150
+ return cls(
151
+ specs=tuple(specs),
152
+ rank=rank,
153
+ subspace_dim=offset,
154
+ compression_ratio=compression,
155
+ )
156
+
157
+ @classmethod
158
+ def auto_from_layout(
159
+ cls,
160
+ layout: ParamLayout,
161
+ compression_ratio: int = 16,
162
+ min_rank: int = 4,
163
+ max_rank: int = 64,
164
+ ) -> LowRankSubspace:
165
+ """Create a LowRankSubspace with auto per-layer rank selection.
166
+
167
+ Computes per-layer rank proportional to min(d_in, d_out) without
168
+ user tuning: rank = max(min_rank, min(max_rank, min(d_in, d_out) // compression_ratio)).
169
+
170
+ Args:
171
+ layout: ParamLayout describing the model's parameter structure.
172
+ compression_ratio: Divisor for rank computation (default 16).
173
+ min_rank: Minimum rank per layer (default 4).
174
+ max_rank: Maximum rank per layer (default 64).
175
+
176
+ Returns:
177
+ LowRankSubspace with per-layer auto-selected ranks (rank=0).
178
+ """
179
+ specs = []
180
+ offset = 0
181
+
182
+ for entry in layout.entries:
183
+ shape = entry.shape
184
+ if len(shape) >= 2:
185
+ d_out = shape[0]
186
+ d_in = math.prod(shape[1:])
187
+ min_dim = min(d_in, d_out)
188
+ auto_rank = max(min_rank, min(max_rank, min_dim // compression_ratio))
189
+ effective_rank = min(auto_rank, d_in, d_out)
190
+ b_shape = (d_out, effective_rank)
191
+ a_shape = (effective_rank, d_in)
192
+ num_elements = d_out * effective_rank + effective_rank * d_in
193
+ specs.append(
194
+ FactorSpec(
195
+ entry_key=entry.key,
196
+ original_shape=shape,
197
+ is_lowrank=True,
198
+ b_shape=b_shape,
199
+ a_shape=a_shape,
200
+ flat_start=offset,
201
+ flat_end=offset + num_elements,
202
+ )
203
+ )
204
+ offset += num_elements
205
+ else:
206
+ num_elements = entry.numel
207
+ specs.append(
208
+ FactorSpec(
209
+ entry_key=entry.key,
210
+ original_shape=shape,
211
+ is_lowrank=False,
212
+ b_shape=(),
213
+ a_shape=(),
214
+ flat_start=offset,
215
+ flat_end=offset + num_elements,
216
+ )
217
+ )
218
+ offset += num_elements
219
+
220
+ total_params = layout.total_params
221
+ compression = offset / total_params if total_params > 0 else 0.0
222
+
223
+ return cls(
224
+ specs=tuple(specs),
225
+ rank=0, # 0 signals auto mode
226
+ subspace_dim=offset,
227
+ compression_ratio=compression,
228
+ )
229
+
230
+ # ------------------------------------------------------------------
231
+ # Core methods
232
+ # ------------------------------------------------------------------
233
+
234
+ def apply_perturbation(
235
+ self,
236
+ base_sd: Dict[str, torch.Tensor],
237
+ flat_subspace: torch.Tensor,
238
+ ) -> Dict[str, torch.Tensor]:
239
+ """Reconstruct full state_dict from base params + flat subspace vector.
240
+
241
+ For low-rank entries, slices out B and A factors, computes delta = B @ A,
242
+ reshapes to original shape, and adds to base. For 1D entries, adds chunk
243
+ directly.
244
+
245
+ Args:
246
+ base_sd: Base state_dict with original parameter values.
247
+ flat_subspace: 1D tensor of shape (subspace_dim,).
248
+
249
+ Returns:
250
+ New state_dict with perturbed parameters.
251
+ """
252
+ result = {}
253
+ for spec in self.specs:
254
+ chunk = flat_subspace[spec.flat_start : spec.flat_end]
255
+ base = base_sd[spec.entry_key]
256
+ if spec.is_lowrank:
257
+ b_size = spec.b_shape[0] * spec.b_shape[1]
258
+ B = chunk[:b_size].reshape(spec.b_shape)
259
+ A = chunk[b_size:].reshape(spec.a_shape)
260
+ delta = (B @ A).reshape(spec.original_shape)
261
+ result[spec.entry_key] = base + delta
262
+ else:
263
+ result[spec.entry_key] = base + chunk.reshape(spec.original_shape)
264
+ return result
265
+
266
+ def reconstruct_batch(
267
+ self,
268
+ base_sd: Dict[str, torch.Tensor],
269
+ flat_subspace_batch: torch.Tensor,
270
+ ) -> Dict[str, torch.Tensor]:
271
+ """Vectorized reconstruction for N probe points using torch.bmm.
272
+
273
+ Args:
274
+ base_sd: Base state_dict with original parameter values.
275
+ flat_subspace_batch: 2D tensor of shape (N, subspace_dim).
276
+
277
+ Returns:
278
+ Dict ``{key: (N, *original_shape)}`` with batched perturbed params.
279
+ """
280
+ N = flat_subspace_batch.shape[0]
281
+ result = {}
282
+ for spec in self.specs:
283
+ chunk = flat_subspace_batch[:, spec.flat_start : spec.flat_end]
284
+ base = base_sd[spec.entry_key]
285
+ if spec.is_lowrank:
286
+ b_size = spec.b_shape[0] * spec.b_shape[1]
287
+ B = chunk[:, :b_size].reshape(N, *spec.b_shape)
288
+ A = chunk[:, b_size:].reshape(N, *spec.a_shape)
289
+ delta_2d = torch.bmm(B, A) # (N, d_out, d_in)
290
+ delta = delta_2d.reshape(N, *spec.original_shape)
291
+ result[spec.entry_key] = base.unsqueeze(0) + delta
292
+ else:
293
+ delta = chunk.reshape(N, *spec.original_shape)
294
+ result[spec.entry_key] = base.unsqueeze(0) + delta
295
+ return result
296
+
297
+ def absorb(
298
+ self,
299
+ base_sd: Dict[str, torch.Tensor],
300
+ flat_subspace: torch.Tensor,
301
+ ) -> Tuple[Dict[str, torch.Tensor], torch.Tensor]:
302
+ """Fold subspace perturbation into base weights and zero the subspace.
303
+
304
+ Args:
305
+ base_sd: Base state_dict to absorb perturbation into.
306
+ flat_subspace: Current subspace vector of shape (subspace_dim,).
307
+
308
+ Returns:
309
+ Tuple of (new_base_sd, zeroed_subspace_vector).
310
+ """
311
+ new_sd = self.apply_perturbation(base_sd, flat_subspace)
312
+ return new_sd, torch.zeros_like(flat_subspace)
313
+
314
+
315
+ # ======================================================================
316
+ # LinearSubspace: random projection (linear mapping)
317
+ # ======================================================================
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class ProjectionSpec:
322
+ """Describes the random projection mapping for a single parameter entry.
323
+
324
+ Attributes:
325
+ entry_key: ParamLayout entry key (e.g., "fc1.weight").
326
+ original_shape: Original parameter shape.
327
+ is_projected: True for 2D+ params (random projection), False for 1D (full).
328
+ num_params: Total elements in this parameter (d_out * d_in).
329
+ num_coords: Number of subspace coordinates allocated.
330
+ flat_start: Start offset into the flat subspace vector.
331
+ flat_end: End offset into the flat subspace vector.
332
+ """
333
+
334
+ entry_key: str
335
+ original_shape: Tuple[int, ...]
336
+ is_projected: bool
337
+ num_params: int
338
+ num_coords: int
339
+ flat_start: int
340
+ flat_end: int
341
+
342
+
343
+ class LinearSubspace:
344
+ """Linear subspace compression via fixed random projection matrices.
345
+
346
+ For each 2D+ parameter, weight perturbation is:
347
+ delta_W = (P @ coords).reshape(original_shape)
348
+ where P is a fixed random projection matrix of shape (num_params, num_coords).
349
+
350
+ Key property: This is LINEAR in coords. When the OT solver probes at
351
+ coords + alpha * direction, the weight change is P @ (coords + alpha * direction)
352
+ = P @ coords + alpha * (P @ direction), which is proportional. The OT solver
353
+ sees meaningful cost differences, enabling convergence.
354
+
355
+ Unlike LowRankSubspace (B@A, bilinear), this avoids the near-zero gradient
356
+ problem that causes uniform OT transport.
357
+
358
+ Note on radius scaling: LinearSubspace requires larger ``step_radius``
359
+ (typically 30x the default) because the random projection matrix has
360
+ 1/sqrt(N) scaling, which dilutes the perturbation magnitude in full
361
+ parameter space.
362
+
363
+ Example::
364
+
365
+ from polystep import LinearSubspace, PolyStepOptimizer
366
+
367
+ subspace = LinearSubspace.from_layout(optimizer.layout, rank=8)
368
+ optimizer = PolyStepOptimizer(
369
+ model, subspace=subspace, step_radius=4.5,
370
+ )
371
+
372
+ See Also:
373
+ ``LowRankSubspace`` for bilinear B@A compression (scalability only).
374
+ ``PolyStepOptimizer`` for subspace integration.
375
+
376
+ Attributes:
377
+ specs: Per-parameter projection specs.
378
+ subspace_dim: Total flat subspace vector size.
379
+ compression_ratio: subspace_dim / total_params for diagnostics.
380
+ seed: Seed for deterministic projection matrix generation.
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ specs: Tuple[ProjectionSpec, ...],
386
+ subspace_dim: int,
387
+ compression_ratio: float,
388
+ seed: int = 0,
389
+ ) -> None:
390
+ self.specs = specs
391
+ self.subspace_dim = subspace_dim
392
+ self.compression_ratio = compression_ratio
393
+ self.seed = seed
394
+ self._projections: Dict[str, torch.Tensor] = {}
395
+
396
+ # ------------------------------------------------------------------
397
+ # Factory methods
398
+ # ------------------------------------------------------------------
399
+
400
+ @classmethod
401
+ def from_layout(
402
+ cls,
403
+ layout: ParamLayout,
404
+ rank: int,
405
+ seed: int = 0,
406
+ max_subspace_dim: Optional[int] = None,
407
+ ) -> LinearSubspace:
408
+ """Create a LinearSubspace with fixed rank from a ParamLayout.
409
+
410
+ Uses the SAME formula as LowRankSubspace for num_coords per layer:
411
+ num_coords = d_out * effective_rank + effective_rank * d_in.
412
+ This makes subspace_dim identical, so switching between the two
413
+ is a drop-in replacement.
414
+
415
+ Args:
416
+ layout: ParamLayout describing the model's parameter structure.
417
+ rank: Rank parameter (controls compression level).
418
+ seed: Seed for deterministic projection matrices.
419
+ max_subspace_dim: Optional cap on total subspace dimension.
420
+
421
+ Returns:
422
+ LinearSubspace with per-layer ProjectionSpecs and flat offsets.
423
+ """
424
+ specs = []
425
+ offset = 0
426
+
427
+ for entry in layout.entries:
428
+ shape = entry.shape
429
+ if len(shape) >= 2:
430
+ d_out = shape[0]
431
+ d_in = math.prod(shape[1:])
432
+ effective_rank = min(rank, d_in, d_out)
433
+ # Same formula as LowRankSubspace for drop-in compatibility
434
+ num_coords = d_out * effective_rank + effective_rank * d_in
435
+ num_params = math.prod(shape)
436
+ specs.append(
437
+ ProjectionSpec(
438
+ entry_key=entry.key,
439
+ original_shape=shape,
440
+ is_projected=True,
441
+ num_params=num_params,
442
+ num_coords=num_coords,
443
+ flat_start=offset,
444
+ flat_end=offset + num_coords,
445
+ )
446
+ )
447
+ offset += num_coords
448
+ else:
449
+ num_elements = entry.numel
450
+ specs.append(
451
+ ProjectionSpec(
452
+ entry_key=entry.key,
453
+ original_shape=shape,
454
+ is_projected=False,
455
+ num_params=num_elements,
456
+ num_coords=num_elements,
457
+ flat_start=offset,
458
+ flat_end=offset + num_elements,
459
+ )
460
+ )
461
+ offset += num_elements
462
+
463
+ # Apply budget cap if specified
464
+ from .hybrid_subspace import _scale_specs_to_budget
465
+
466
+ specs, offset = _scale_specs_to_budget(specs, max_subspace_dim, offset)
467
+
468
+ total_params = layout.total_params
469
+ compression = offset / total_params if total_params > 0 else 0.0
470
+
471
+ inst = cls(
472
+ specs=tuple(specs),
473
+ subspace_dim=offset,
474
+ compression_ratio=compression,
475
+ seed=seed,
476
+ )
477
+ inst._max_subspace_dim = max_subspace_dim
478
+ return inst
479
+
480
+ @classmethod
481
+ def auto_from_layout(
482
+ cls,
483
+ layout: ParamLayout,
484
+ compression_ratio: int = 16,
485
+ min_rank: int = 4,
486
+ max_rank: int = 64,
487
+ seed: int = 0,
488
+ max_subspace_dim: Optional[int] = None,
489
+ ) -> LinearSubspace:
490
+ """Create a LinearSubspace with auto per-layer rank selection.
491
+
492
+ Same auto-rank logic as LowRankSubspace: per-layer rank proportional
493
+ to min(d_in, d_out) without user tuning.
494
+
495
+ Args:
496
+ layout: ParamLayout describing the model's parameter structure.
497
+ compression_ratio: Divisor for rank computation (default 16).
498
+ min_rank: Minimum rank per layer (default 4).
499
+ max_rank: Maximum rank per layer (default 64).
500
+ seed: Seed for deterministic projection matrices.
501
+
502
+ Returns:
503
+ LinearSubspace with per-layer auto-selected coordinate counts.
504
+ """
505
+ specs = []
506
+ offset = 0
507
+
508
+ for entry in layout.entries:
509
+ shape = entry.shape
510
+ if len(shape) >= 2:
511
+ d_out = shape[0]
512
+ d_in = math.prod(shape[1:])
513
+ min_dim = min(d_in, d_out)
514
+ auto_rank = max(min_rank, min(max_rank, min_dim // compression_ratio))
515
+ effective_rank = min(auto_rank, d_in, d_out)
516
+ num_coords = d_out * effective_rank + effective_rank * d_in
517
+ num_params = math.prod(shape)
518
+ specs.append(
519
+ ProjectionSpec(
520
+ entry_key=entry.key,
521
+ original_shape=shape,
522
+ is_projected=True,
523
+ num_params=num_params,
524
+ num_coords=num_coords,
525
+ flat_start=offset,
526
+ flat_end=offset + num_coords,
527
+ )
528
+ )
529
+ offset += num_coords
530
+ else:
531
+ num_elements = entry.numel
532
+ specs.append(
533
+ ProjectionSpec(
534
+ entry_key=entry.key,
535
+ original_shape=shape,
536
+ is_projected=False,
537
+ num_params=num_elements,
538
+ num_coords=num_elements,
539
+ flat_start=offset,
540
+ flat_end=offset + num_elements,
541
+ )
542
+ )
543
+ offset += num_elements
544
+
545
+ # Apply budget cap if specified
546
+ from .hybrid_subspace import _scale_specs_to_budget
547
+
548
+ specs, offset = _scale_specs_to_budget(specs, max_subspace_dim, offset)
549
+
550
+ total_params = layout.total_params
551
+ compression = offset / total_params if total_params > 0 else 0.0
552
+
553
+ inst = cls(
554
+ specs=tuple(specs),
555
+ subspace_dim=offset,
556
+ compression_ratio=compression,
557
+ seed=seed,
558
+ )
559
+ inst._max_subspace_dim = max_subspace_dim
560
+ return inst
561
+
562
+ # ------------------------------------------------------------------
563
+ # Projection matrix generation
564
+ # ------------------------------------------------------------------
565
+
566
+ def _get_projection(
567
+ self,
568
+ spec: ProjectionSpec,
569
+ device: torch.device,
570
+ dtype: torch.dtype,
571
+ ) -> torch.Tensor:
572
+ """Get or generate the projection matrix for a parameter entry.
573
+
574
+ Generates P of shape (num_params, num_coords) deterministically from
575
+ self.seed and spec.entry_key. Columns are scaled by 1/sqrt(num_coords)
576
+ for unit-variance output (standard random projection scaling).
577
+
578
+ Args:
579
+ spec: ProjectionSpec for this parameter.
580
+ device: Target device.
581
+ dtype: Target dtype.
582
+
583
+ Returns:
584
+ Projection matrix P of shape (num_params, num_coords).
585
+ """
586
+ cache_key = (spec.entry_key, str(device), str(dtype))
587
+ if cache_key in self._projections:
588
+ cached = self._projections[cache_key]
589
+ if cached.device == device and cached.dtype == dtype:
590
+ return cached
591
+
592
+ # Deterministic seed from global seed + entry key
593
+ entry_seed = _stable_entry_seed(self.seed, spec.entry_key)
594
+ gen = torch.Generator(device="cpu")
595
+ gen.manual_seed(entry_seed)
596
+
597
+ # Generate on CPU then move (Generator doesn't support CUDA)
598
+ P = torch.randn(
599
+ spec.num_params,
600
+ spec.num_coords,
601
+ generator=gen,
602
+ dtype=dtype,
603
+ device="cpu",
604
+ )
605
+ # Scale for unit-variance output
606
+ P = P * (1.0 / math.sqrt(spec.num_coords))
607
+ P = P.to(device=device)
608
+
609
+ self._projections[cache_key] = P
610
+ return P
611
+
612
+ # ------------------------------------------------------------------
613
+ # Core methods
614
+ # ------------------------------------------------------------------
615
+
616
+ def apply_perturbation(
617
+ self,
618
+ base_sd: Dict[str, torch.Tensor],
619
+ flat_subspace: torch.Tensor,
620
+ ) -> Dict[str, torch.Tensor]:
621
+ """Reconstruct full state_dict from base params + flat subspace vector.
622
+
623
+ For projected entries, slices coords, matrix-multiplies with projection P,
624
+ reshapes to original shape, and adds to base. For 1D entries, adds directly.
625
+
626
+ Args:
627
+ base_sd: Base state_dict with original parameter values.
628
+ flat_subspace: 1D tensor of shape (subspace_dim,).
629
+
630
+ Returns:
631
+ New state_dict with perturbed parameters.
632
+ """
633
+ result = {}
634
+ for spec in self.specs:
635
+ chunk = flat_subspace[spec.flat_start : spec.flat_end]
636
+ base = base_sd[spec.entry_key]
637
+ if spec.is_projected:
638
+ P = self._get_projection(spec, base.device, base.dtype)
639
+ delta = (P @ chunk).reshape(spec.original_shape)
640
+ result[spec.entry_key] = base + delta
641
+ else:
642
+ result[spec.entry_key] = base + chunk.reshape(spec.original_shape)
643
+ return result
644
+
645
+ def reconstruct_batch(
646
+ self,
647
+ base_sd: Dict[str, torch.Tensor],
648
+ flat_subspace_batch: torch.Tensor,
649
+ ) -> Dict[str, torch.Tensor]:
650
+ """Vectorized reconstruction for N probe points.
651
+
652
+ For projected entries: coords @ P.T gives (N, num_params), then reshape.
653
+ This avoids bmm -- simple matmul.
654
+
655
+ Args:
656
+ base_sd: Base state_dict with original parameter values.
657
+ flat_subspace_batch: 2D tensor of shape (N, subspace_dim).
658
+
659
+ Returns:
660
+ Dict ``{key: (N, *original_shape)}`` with batched perturbed params.
661
+ """
662
+ N = flat_subspace_batch.shape[0]
663
+ result = {}
664
+ for spec in self.specs:
665
+ chunk = flat_subspace_batch[:, spec.flat_start : spec.flat_end]
666
+ base = base_sd[spec.entry_key]
667
+ if spec.is_projected:
668
+ P = self._get_projection(spec, base.device, base.dtype)
669
+ # chunk: (N, num_coords), P: (num_params, num_coords)
670
+ # delta_flat = chunk @ P.T -> (N, num_params)
671
+ delta_flat = chunk @ P.t()
672
+ delta = delta_flat.reshape(N, *spec.original_shape)
673
+ result[spec.entry_key] = base.unsqueeze(0) + delta
674
+ else:
675
+ delta = chunk.reshape(N, *spec.original_shape)
676
+ result[spec.entry_key] = base.unsqueeze(0) + delta
677
+ return result
678
+
679
+ def absorb(
680
+ self,
681
+ base_sd: Dict[str, torch.Tensor],
682
+ flat_subspace: torch.Tensor,
683
+ ) -> Tuple[Dict[str, torch.Tensor], torch.Tensor]:
684
+ """Fold subspace perturbation into base weights and zero the subspace.
685
+
686
+ Args:
687
+ base_sd: Base state_dict to absorb perturbation into.
688
+ flat_subspace: Current subspace vector of shape (subspace_dim,).
689
+
690
+ Returns:
691
+ Tuple of (new_base_sd, zeroed_subspace_vector).
692
+ """
693
+ new_sd = self.apply_perturbation(base_sd, flat_subspace)
694
+ return new_sd, torch.zeros_like(flat_subspace)