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/geometry.py ADDED
@@ -0,0 +1,410 @@
1
+ """Geometry module: polytope templates, rotation matrices, and probe generation.
2
+
3
+ Combines polytope vertex generators (orthoplex, simplex, cube), random rotation
4
+ via QR decomposition (Mezzadri method) or analytical 2D formula, and deterministic
5
+ probe point generation into a single module for PolyStep exploration directions.
6
+ """
7
+
8
+ import math
9
+ from typing import Callable, Dict, Optional, Tuple
10
+
11
+ import torch
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Section 1: Polytope vertex templates
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def get_orthoplex_vertices(
20
+ dim: int,
21
+ *,
22
+ device: Optional[torch.device] = None,
23
+ dtype: Optional[torch.dtype] = None,
24
+ radius: float = 1.0,
25
+ **kwargs,
26
+ ) -> torch.Tensor:
27
+ """Generate orthoplex (cross-polytope) vertices centered at the origin.
28
+
29
+ Produces 2*dim vertices: the positive and negative unit vectors along each axis.
30
+
31
+ Args:
32
+ dim: Dimensionality of the vertices.
33
+ device: Target device for the output tensor.
34
+ dtype: Target dtype for the output tensor.
35
+ radius: Scaling radius.
36
+
37
+ Returns:
38
+ Vertices of shape (2*dim, dim).
39
+ """
40
+ eye = torch.eye(dim, dtype=dtype, device=device)
41
+ points = torch.cat([eye, -eye], dim=0)
42
+ return points * radius
43
+
44
+
45
+ def get_simplex_vertices(
46
+ dim: int,
47
+ *,
48
+ device: Optional[torch.device] = None,
49
+ dtype: Optional[torch.dtype] = None,
50
+ radius: float = 1.0,
51
+ **kwargs,
52
+ ) -> torch.Tensor:
53
+ """Generate regular simplex vertices centered at the origin.
54
+
55
+ Produces dim+1 vertices forming a regular simplex.
56
+
57
+ Args:
58
+ dim: Dimensionality of the vertices.
59
+ device: Target device for the output tensor.
60
+ dtype: Target dtype for the output tensor.
61
+ radius: Scaling radius.
62
+
63
+ Returns:
64
+ Vertices of shape (dim+1, dim).
65
+ """
66
+ points = math.sqrt(1 + 1 / dim) * torch.eye(dim, dtype=dtype, device=device)
67
+ points = points - ((math.sqrt(dim + 1) + 1) / math.sqrt(dim**3))
68
+
69
+ last_vertex = (1 / math.sqrt(dim)) * torch.ones(1, dim, dtype=dtype, device=device)
70
+ points = torch.cat([points, last_vertex], dim=0)
71
+
72
+ # Center simplex at origin (unlike orthoplex/cube, simplex is not inherently symmetric)
73
+ centroid = points.mean(dim=0, keepdim=True)
74
+ points = points - centroid
75
+
76
+ return points * radius
77
+
78
+
79
+ def get_cube_vertices(
80
+ dim: int,
81
+ *,
82
+ device: Optional[torch.device] = None,
83
+ dtype: Optional[torch.dtype] = None,
84
+ radius: float = 1.0,
85
+ **kwargs,
86
+ ) -> torch.Tensor:
87
+ """Generate hypercube vertices using bitwise logic.
88
+
89
+ Produces 2^dim vertices: all sign combinations normalized by sqrt(dim).
90
+
91
+ Args:
92
+ dim: Dimensionality of the vertices.
93
+ device: Target device for the output tensor.
94
+ dtype: Target dtype for the output tensor.
95
+ radius: Scaling radius.
96
+
97
+ Returns:
98
+ Vertices of shape (2^dim, dim).
99
+ """
100
+ n_vertices = 2**dim
101
+
102
+ indices = torch.arange(n_vertices, dtype=torch.int32, device=device).unsqueeze(1)
103
+ shifts = torch.arange(dim, dtype=torch.int32, device=device).unsqueeze(0)
104
+
105
+ bits = (indices >> shifts) & 1
106
+ # Resolve dtype for the float conversion (default to float32 if None)
107
+ float_dtype = dtype if dtype is not None else torch.float32
108
+ signs = 1.0 - 2.0 * bits.to(float_dtype)
109
+
110
+ points = signs / math.sqrt(dim)
111
+ return points * radius
112
+
113
+
114
+ POLYTOPE_MAP: Dict[str, Callable] = {
115
+ "cube": get_cube_vertices,
116
+ "orthoplex": get_orthoplex_vertices,
117
+ "simplex": get_simplex_vertices,
118
+ }
119
+
120
+ POLYTOPE_NUM_VERTICES_MAP: Dict[str, Callable[[int], int]] = {
121
+ "cube": lambda dim: 2**dim,
122
+ "orthoplex": lambda dim: 2 * dim,
123
+ "simplex": lambda dim: dim + 1,
124
+ }
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Section 2: Rotation matrices
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def get_rotation_matrix_2d(theta: torch.Tensor) -> torch.Tensor:
133
+ """Create 2x2 rotation matrices from angles using analytical formula.
134
+
135
+ Args:
136
+ theta: Angles tensor of shape (...).
137
+
138
+ Returns:
139
+ Rotation matrices of shape (..., 2, 2).
140
+ """
141
+ c = torch.cos(theta)
142
+ s = torch.sin(theta)
143
+ row1 = torch.stack([c, -s], dim=-1)
144
+ row2 = torch.stack([s, c], dim=-1)
145
+ return torch.stack([row1, row2], dim=-2)
146
+
147
+
148
+ def get_random_rotation_matrices(
149
+ batch: int,
150
+ dim: int,
151
+ device: Optional[torch.device] = None,
152
+ dtype: Optional[torch.dtype] = None,
153
+ generator: Optional[torch.Generator] = None,
154
+ ) -> torch.Tensor:
155
+ """Generate batched uniformly random rotation matrices on SO(dim).
156
+
157
+ For dim=2, uses fast analytical rotation. For dim>2, uses batched QR
158
+ decomposition with Mezzadri sign correction for Haar measure.
159
+
160
+ Ref: F. Mezzadri, "How to generate random matrices from the
161
+ classical compact groups" (arXiv:math-ph/0609050).
162
+
163
+ Args:
164
+ batch: Number of rotation matrices to generate.
165
+ dim: Dimension of each rotation matrix.
166
+ device: Target device.
167
+ dtype: Target dtype.
168
+ generator: Optional torch.Generator for reproducibility.
169
+
170
+ Returns:
171
+ Rotation matrices of shape (batch, dim, dim) with det(R) = +1.
172
+ """
173
+ if dim == 2:
174
+ angles = torch.empty(batch, device=device, dtype=dtype)
175
+ angles.uniform_(0, 2 * math.pi, generator=generator)
176
+ return get_rotation_matrix_2d(angles)
177
+
178
+ # Batched QR decomposition for dim > 2
179
+ # QR decomposition requires FP32 on CPU (BF16 not supported for geqrf_cpu)
180
+ # Generate in FP32, compute QR, then convert to target dtype
181
+ # Resolve None device/dtype to concrete values for comparison
182
+ resolved_device = device if device is not None else torch.device("cpu")
183
+ resolved_dtype = dtype if dtype is not None else torch.float32
184
+ device_type = resolved_device.type if hasattr(resolved_device, "type") else str(resolved_device)
185
+ is_cpu = device_type == "cpu"
186
+ needs_fp32_qr = resolved_dtype == torch.bfloat16 and is_cpu
187
+ compute_dtype = torch.float32 if needs_fp32_qr else resolved_dtype
188
+ compute_device = "cpu" if needs_fp32_qr else resolved_device
189
+
190
+ # Generator device must match tensor device for ``randn``. If the user
191
+ # supplied a CUDA generator but we have to run QR on CPU (bfloat16
192
+ # fallback), sample on the generator's device first, then move the
193
+ # result to the QR compute device. This preserves reproducibility.
194
+ gen_for_randn = generator
195
+ sample_device = compute_device
196
+ needs_post_move = False
197
+ if generator is not None and hasattr(generator, "device"):
198
+ gen_device_type = generator.device.type if hasattr(generator.device, "type") else str(generator.device)
199
+ compute_device_type = "cpu" if needs_fp32_qr else device_type
200
+ if gen_device_type != compute_device_type:
201
+ sample_device = generator.device
202
+ needs_post_move = True
203
+
204
+ Z = torch.randn(batch, dim, dim, device=sample_device, dtype=compute_dtype, generator=gen_for_randn)
205
+ if needs_post_move:
206
+ Z = Z.to(device=compute_device)
207
+ Q, R = torch.linalg.qr(Z)
208
+
209
+ # Sign correction for Haar measure (Mezzadri method). Use a nonzero sign
210
+ # (sign(0) := +1) so an underflowed zero R-diagonal can't null a column.
211
+ d = torch.diagonal(R, dim1=-2, dim2=-1) # (batch, dim)
212
+ phases = torch.where(d == 0, torch.ones_like(d), torch.sign(d))
213
+ Q = Q * phases.unsqueeze(-2) # (batch, 1, dim) * (batch, dim, dim)
214
+
215
+ # Ensure det = +1 (SO(n) not just O(n)): flip first column where det < 0.
216
+ # Q is a fresh tensor (product above), so the in-place column flip is safe.
217
+ flip = torch.where(torch.det(Q) < 0, -1.0, 1.0).to(Q.dtype) # (batch,)
218
+ Q[:, :, 0] = Q[:, :, 0] * flip.unsqueeze(-1)
219
+
220
+ # Convert to target dtype and device
221
+ Q = Q.to(device=device, dtype=dtype)
222
+
223
+ return Q
224
+
225
+
226
+ def apply_biased_rotation(rot_mats: torch.Tensor, bias_dir_norm: torch.Tensor) -> torch.Tensor:
227
+ """Bias the first rotation axis toward ``bias_dir_norm`` and re-orthonormalize.
228
+
229
+ Replaces column 0 with the unit bias direction, re-orthonormalizes via QR,
230
+ and flips the last column where needed so det = +1 (a proper rotation).
231
+ Batch elements where QR is non-finite fall back to the input rotation.
232
+
233
+ Args:
234
+ rot_mats: Rotation matrices of shape (batch, dim, dim).
235
+ bias_dir_norm: Unit bias directions of shape (batch, dim).
236
+
237
+ Returns:
238
+ Biased rotation matrices of shape (batch, dim, dim), det = +1.
239
+ """
240
+ if rot_mats.shape[-1] == 1:
241
+ # SO(1) = {[[1]]}: the only proper 1D rotation is the identity, so
242
+ # aligning axis 0 with the bias is not representable (a sign flip is a
243
+ # reflection). Return identity instead of flipping the aligned axis back.
244
+ return torch.ones_like(rot_mats)
245
+ biased = rot_mats.clone()
246
+ biased[:, :, 0] = bias_dir_norm
247
+ # geqrf has no bf16/fp16 CPU kernel; run QR in fp32, cast back.
248
+ qr_in = biased.float() if biased.dtype in (torch.bfloat16, torch.float16) else biased
249
+ Q, _ = torch.linalg.qr(qr_in)
250
+ Q = Q.to(biased.dtype)
251
+ valid = torch.isfinite(Q).all(dim=-1).all(dim=-1) # (batch,)
252
+ out = torch.where(valid[:, None, None], Q, rot_mats)
253
+ # QR fixes column 0 only up to sign; realign it with the bias so the search
254
+ # points toward descent, not ascent (sign 0 -> +1).
255
+ dot0 = (out[:, :, 0] * bias_dir_norm).sum(dim=1) # (batch,)
256
+ sign0 = torch.where(dot0 < 0, -1.0, 1.0).to(out.dtype)
257
+ out[:, :, 0] = out[:, :, 0] * sign0.unsqueeze(-1)
258
+ # Flip the last column to keep det = +1 (det has no bf16 CPU kernel, use fp32).
259
+ flip = (torch.det(out.float()) < 0).unsqueeze(-1)
260
+ out[:, :, -1] = torch.where(flip, -out[:, :, -1], out[:, :, -1])
261
+ return out
262
+
263
+
264
+ def get_sobol_rotation_matrices(
265
+ batch: int,
266
+ dim: int,
267
+ device: torch.device = torch.device("cpu"),
268
+ dtype: torch.dtype = torch.float32,
269
+ engine: Optional[torch.quasirandom.SobolEngine] = None,
270
+ ) -> torch.Tensor:
271
+ """Generate rotation matrices from Sobol quasi-random sequences.
272
+
273
+ Low-discrepancy sequences ensure better coverage of SO(d) over
274
+ multiple calls than purely random rotations.
275
+
276
+ Args:
277
+ batch: Number of rotation matrices.
278
+ dim: Dimension of the rotation.
279
+ device: Target device.
280
+ dtype: Target dtype.
281
+ engine: Optional pre-initialized SobolEngine (for stateful sequencing).
282
+
283
+ Returns:
284
+ Rotation matrices of shape (batch, dim, dim) in SO(dim).
285
+ """
286
+ if dim == 2:
287
+ # 2D: Sobol angle in [0, 2pi)
288
+ if engine is None:
289
+ engine = torch.quasirandom.SobolEngine(dimension=1, scramble=True)
290
+ uniform = engine.draw(batch).to(device=device, dtype=dtype) # (batch, 1)
291
+ angles = uniform[:, 0] * 2 * math.pi
292
+ return get_rotation_matrix_2d(angles)
293
+
294
+ # Higher dimensions: Sobol-driven QR (Mezzadri method with quasi-random input)
295
+ sobol_dim = dim * dim
296
+ if engine is None:
297
+ engine = torch.quasirandom.SobolEngine(dimension=sobol_dim, scramble=True)
298
+ uniform = engine.draw(batch) # (batch, dim*dim) in [0, 1]
299
+
300
+ # Transform uniform to normal via inverse CDF (erfinv)
301
+ uniform = uniform.clamp(1e-6, 1 - 1e-6)
302
+ normal = torch.erfinv(2 * uniform - 1) * math.sqrt(2)
303
+ normal = normal.to(device=device, dtype=dtype)
304
+
305
+ Z = normal.reshape(batch, dim, dim)
306
+ Q, R = torch.linalg.qr(Z)
307
+
308
+ # Mezzadri sign correction (sign(0) := +1 to keep columns nonzero)
309
+ d = torch.diagonal(R, dim1=-2, dim2=-1)
310
+ phases = torch.where(d == 0, torch.ones_like(d), torch.sign(d))
311
+ Q = Q * phases.unsqueeze(-2)
312
+
313
+ # Ensure det = +1 (Q is a fresh tensor, so the in-place flip is safe)
314
+ flip = torch.where(torch.det(Q) < 0, -1.0, 1.0).to(Q.dtype)
315
+ Q[:, :, 0] = Q[:, :, 0] * flip.unsqueeze(-1)
316
+
317
+ return Q
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Section 3: Probe point generation
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def get_probe_points(
326
+ origin: torch.Tensor,
327
+ directions: torch.Tensor,
328
+ scales: torch.Tensor,
329
+ probe_radius: float = 2.0,
330
+ ) -> torch.Tensor:
331
+ """Generate probe points at fixed scale intervals along directions.
332
+
333
+ Args:
334
+ origin: Center positions of shape (batch, dim).
335
+ directions: Direction vectors of shape (batch, num_points, dim).
336
+ scales: Scalar coefficients of shape (num_probe,).
337
+ probe_radius: Maximum distance multiplier.
338
+
339
+ Returns:
340
+ Probe points of shape (batch, num_points, num_probe, dim).
341
+ """
342
+ # origin: (batch, 1, 1, dim)
343
+ origin_exp = origin[:, None, None, :]
344
+ # directions: (batch, num_points, 1, dim)
345
+ directions_exp = directions[:, :, None, :]
346
+ # scales: (1, 1, num_probe, 1)
347
+ scales_exp = scales[None, None, :, None]
348
+
349
+ return origin_exp + (directions_exp * probe_radius) * scales_exp
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Section 4: Main sampling pipeline
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ def get_sampled_polytope_vertices(
358
+ origin: torch.Tensor,
359
+ probes: torch.Tensor,
360
+ polytope_vertices: torch.Tensor,
361
+ step_radius: float = 1.0,
362
+ probe_radius: float = 2.0,
363
+ generator: Optional[torch.Generator] = None,
364
+ **kwargs,
365
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
366
+ """Rotate a polytope template and generate deterministic probes.
367
+
368
+ Applies a random rotation to the polytope vertices for each particle,
369
+ translates by the step radius, and generates probe points at multiple
370
+ radii along each direction.
371
+
372
+ Args:
373
+ origin: Particle positions of shape (batch, dim) or (dim,).
374
+ probes: Scalar probe scales of shape (num_probe,).
375
+ polytope_vertices: Template vertices of shape (num_vertices, dim).
376
+ step_radius: Step distance multiplier.
377
+ probe_radius: Probe distance multiplier.
378
+ generator: Optional torch.Generator for reproducibility.
379
+
380
+ Returns:
381
+ Tuple of (step_points, probe_points, rotated_vertices):
382
+ - step_points: (batch, num_verts, dim) vertex positions after rotation + translation
383
+ - probe_points: (batch, num_verts, num_probe, dim) probe points along each direction
384
+ - rotated_vertices: (batch, num_verts, dim) rotated vertices before translation
385
+ """
386
+ if origin.dim() == 1:
387
+ origin = origin.unsqueeze(0)
388
+ batch, dim = origin.shape
389
+
390
+ # 1. Generate rotation matrices (batch, dim, dim)
391
+ rot_mats = get_random_rotation_matrices(
392
+ batch,
393
+ dim,
394
+ device=origin.device,
395
+ dtype=origin.dtype,
396
+ generator=generator,
397
+ )
398
+
399
+ # 2. Apply rotation: R @ v for each vertex
400
+ # rot_mats: (batch, dim, dim), polytope_vertices: (num_verts, dim)
401
+ # Result: (batch, num_verts, dim)
402
+ rotated_vertices = torch.einsum("bji, ni -> bnj", rot_mats, polytope_vertices)
403
+
404
+ # 3. Translate step points
405
+ step_points = rotated_vertices * step_radius + origin.unsqueeze(1)
406
+
407
+ # 4. Generate probes (deterministic scaling)
408
+ probe_points = get_probe_points(origin, rotated_vertices, probes, probe_radius)
409
+
410
+ return step_points, probe_points, rotated_vertices