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,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
|