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/blockwise.py ADDED
@@ -0,0 +1,549 @@
1
+ """Block-wise Sinkhorn decomposition for scaling to large parameter spaces.
2
+
3
+ Instead of solving a single OT problem over all particles, decomposes into
4
+ independent smaller OT problems per block (e.g. per layer). Each block has
5
+ its own polytope, cost matrix, and OT solve.
6
+
7
+ Sinkhorn is O(n^2) in particle count. Splitting M particles into L blocks
8
+ of M/L reduces total cost from O(M^2) to O(L * (M/L)^2) = O(M^2/L).
9
+
10
+ Usage::
11
+
12
+ optimizer = PolyStepOptimizer(model, block_strategy='per_layer')
13
+
14
+ See Also:
15
+ ``PolyStepOptimizer`` for the ``block_strategy`` parameter.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import warnings
21
+ from dataclasses import dataclass
22
+ from typing import List, Optional, Tuple, TYPE_CHECKING
23
+
24
+ import torch
25
+
26
+ if TYPE_CHECKING:
27
+ from .transform import ParamLayout
28
+ from .cost_nn import NNCostEvaluator
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class BlockConfig:
33
+ """Configuration for a single block in block-wise Sinkhorn.
34
+
35
+ Attributes:
36
+ name: Human-readable name for the block (e.g. 'fc1.weight').
37
+ leaf_indices: Indices into ParamLayout.entries belonging to this block.
38
+ flat_start: Start offset in the block-wise flat particle vector.
39
+ flat_end: End offset in the block-wise flat particle vector.
40
+ num_particles: Number of particle rows in this block.
41
+ particle_dim: Dimension of each particle in this block.
42
+ """
43
+
44
+ name: str
45
+ leaf_indices: Tuple[int, ...]
46
+ flat_start: int
47
+ flat_end: int
48
+ num_particles: int
49
+ particle_dim: int
50
+
51
+
52
+ def create_per_layer_blocks(
53
+ layout: ParamLayout,
54
+ particle_dim: int = 2,
55
+ ) -> List[BlockConfig]:
56
+ """Create one block per parameter entry (layer) in the layout.
57
+
58
+ Each block computes its own padding independently so that
59
+ ``num_params`` is divisible by ``particle_dim``.
60
+
61
+ Args:
62
+ layout: ParamLayout describing model parameter structure.
63
+ particle_dim: Number of elements per particle row.
64
+
65
+ Returns:
66
+ List of BlockConfig, one per layout entry.
67
+ """
68
+ blocks: List[BlockConfig] = []
69
+ offset = 0
70
+
71
+ for i, entry in enumerate(layout.entries):
72
+ num_params = entry.numel
73
+ padded = num_params + (-num_params % particle_dim)
74
+ num_particles = padded // particle_dim
75
+ blocks.append(
76
+ BlockConfig(
77
+ name=entry.key,
78
+ leaf_indices=(i,),
79
+ flat_start=offset,
80
+ flat_end=offset + padded,
81
+ num_particles=num_particles,
82
+ particle_dim=particle_dim,
83
+ )
84
+ )
85
+ offset += padded
86
+
87
+ return blocks
88
+
89
+
90
+ def create_grouped_blocks(
91
+ layout: ParamLayout,
92
+ group_size: int = 2,
93
+ particle_dim: int = 2,
94
+ ) -> List[BlockConfig]:
95
+ """Create blocks by grouping consecutive layout entries.
96
+
97
+ Bundles consecutive entries (e.g. weight+bias pairs) into blocks.
98
+ Each group's total element count is padded independently.
99
+
100
+ Args:
101
+ layout: ParamLayout describing model parameter structure.
102
+ group_size: Number of consecutive entries per block.
103
+ particle_dim: Number of elements per particle row.
104
+
105
+ Returns:
106
+ List of BlockConfig.
107
+ """
108
+ blocks: List[BlockConfig] = []
109
+ offset = 0
110
+ entries = layout.entries
111
+
112
+ for g_start in range(0, len(entries), group_size):
113
+ g_end = min(g_start + group_size, len(entries))
114
+ group_entries = entries[g_start:g_end]
115
+ leaf_indices = tuple(range(g_start, g_end))
116
+ num_params = sum(e.numel for e in group_entries)
117
+ padded = num_params + (-num_params % particle_dim)
118
+ num_particles = padded // particle_dim
119
+ blocks.append(
120
+ BlockConfig(
121
+ name=f"block_{g_start}_{g_end}",
122
+ leaf_indices=leaf_indices,
123
+ flat_start=offset,
124
+ flat_end=offset + padded,
125
+ num_particles=num_particles,
126
+ particle_dim=particle_dim,
127
+ )
128
+ )
129
+ offset += padded
130
+
131
+ return blocks
132
+
133
+
134
+ def split_particles(
135
+ particles: torch.Tensor,
136
+ blocks: List[BlockConfig],
137
+ ) -> List[torch.Tensor]:
138
+ """Slice full flat particle vector into per-block particle tensors.
139
+
140
+ Args:
141
+ particles: 2D tensor of shape ``(total_particles, particle_dim)`` or
142
+ 1D flat tensor. Internally flattened and sliced by block offsets.
143
+ blocks: List of BlockConfig with flat_start/flat_end offsets.
144
+
145
+ Returns:
146
+ List of tensors, each ``(block.num_particles, block.particle_dim)``.
147
+ """
148
+ flat = particles.reshape(-1)
149
+ result: List[torch.Tensor] = []
150
+
151
+ for block in blocks:
152
+ block_flat = flat[block.flat_start : block.flat_end]
153
+ result.append(block_flat.reshape(block.num_particles, block.particle_dim))
154
+
155
+ return result
156
+
157
+
158
+ @torch.inference_mode()
159
+ def reassemble_blocks(
160
+ block_particles: List[torch.Tensor],
161
+ blocks: List[BlockConfig],
162
+ total_flat_size: int,
163
+ ) -> torch.Tensor:
164
+ """Reconstruct full flat vector from per-block particle tensors.
165
+
166
+ Args:
167
+ block_particles: List of tensors, each
168
+ ``(block.num_particles, block.particle_dim)``.
169
+ blocks: List of BlockConfig with flat_start/flat_end offsets.
170
+ total_flat_size: Total size of the reassembled flat vector
171
+ (sum of all block padded sizes).
172
+
173
+ Returns:
174
+ 1D tensor of shape ``(total_flat_size,)``.
175
+ """
176
+ if not block_particles:
177
+ return torch.zeros(total_flat_size)
178
+
179
+ full_flat = torch.zeros(total_flat_size, dtype=block_particles[0].dtype, device=block_particles[0].device)
180
+
181
+ for block_X, block in zip(block_particles, blocks):
182
+ block_flat = block_X.reshape(-1)
183
+ block_size = block.flat_end - block.flat_start
184
+ full_flat[block.flat_start : block.flat_end] = block_flat[:block_size]
185
+
186
+ return full_flat
187
+
188
+
189
+ @torch.inference_mode()
190
+ def blocks_to_layout_flat(
191
+ block_flat: torch.Tensor,
192
+ blocks: List[BlockConfig],
193
+ layout: "ParamLayout",
194
+ ) -> torch.Tensor:
195
+ """Map block-indexed flat vector to layout-indexed flat vector.
196
+
197
+ Per-layer (and grouped) blocks pad each block independently, creating a
198
+ different offset scheme than ``ParamLayout`` (which concatenates all
199
+ entries contiguously and pads once at the end). This function copies
200
+ actual parameter data from block offsets to the correct layout offsets,
201
+ producing a vector suitable for ``layout.batch_unflatten``.
202
+
203
+ Handles both per-layer blocks (one entry per block) and grouped blocks
204
+ (multiple entries per block) via ``block.leaf_indices``.
205
+
206
+ Args:
207
+ block_flat: 1D tensor in block-indexed layout (from ``reassemble_blocks``).
208
+ blocks: List of BlockConfig with ``leaf_indices`` into ``layout.entries``.
209
+ layout: ParamLayout for the model.
210
+
211
+ Returns:
212
+ 1D tensor of shape ``(layout.padded_size,)`` in layout-indexed layout.
213
+ """
214
+ layout_flat = torch.zeros(
215
+ layout.padded_size,
216
+ dtype=block_flat.dtype,
217
+ device=block_flat.device,
218
+ )
219
+
220
+ for block in blocks:
221
+ internal_offset = 0
222
+ for leaf_idx in block.leaf_indices:
223
+ entry = layout.entries[leaf_idx]
224
+ numel = entry.numel
225
+ layout_flat[entry.offset : entry.offset + numel] = block_flat[
226
+ block.flat_start + internal_offset : block.flat_start + internal_offset + numel
227
+ ]
228
+ internal_offset += numel
229
+
230
+ return layout_flat
231
+
232
+
233
+ @torch.inference_mode()
234
+ def layout_flat_to_block_flat(
235
+ layout_flat: torch.Tensor,
236
+ blocks: List[BlockConfig],
237
+ layout: "ParamLayout",
238
+ ) -> torch.Tensor:
239
+ """Map layout-indexed flat vector to block-indexed flat vector.
240
+
241
+ Inverse of ``blocks_to_layout_flat``. Extracts actual parameter data
242
+ from layout entry offsets and places it at the correct block offsets,
243
+ with per-block padding zeros.
244
+
245
+ Args:
246
+ layout_flat: 1D tensor of shape ``(layout.padded_size,)`` in
247
+ layout-indexed format (e.g. from ``layout.flatten(model)``).
248
+ blocks: List of BlockConfig with ``leaf_indices`` into ``layout.entries``.
249
+ layout: ParamLayout for the model.
250
+
251
+ Returns:
252
+ 1D tensor of shape ``(total_block_flat_size,)`` in block-indexed layout.
253
+ """
254
+ total_block_flat_size = blocks[-1].flat_end if blocks else 0
255
+ block_flat = torch.zeros(
256
+ total_block_flat_size,
257
+ dtype=layout_flat.dtype,
258
+ device=layout_flat.device,
259
+ )
260
+
261
+ for block in blocks:
262
+ internal_offset = 0
263
+ for leaf_idx in block.leaf_indices:
264
+ entry = layout.entries[leaf_idx]
265
+ numel = entry.numel
266
+ block_flat[block.flat_start + internal_offset : block.flat_start + internal_offset + numel] = layout_flat[
267
+ entry.offset : entry.offset + numel
268
+ ]
269
+ internal_offset += numel
270
+
271
+ return block_flat
272
+
273
+
274
+ @torch.inference_mode()
275
+ def blocks_to_layout_flat_batch(
276
+ block_flat_batch: torch.Tensor,
277
+ blocks: List[BlockConfig],
278
+ layout: "ParamLayout",
279
+ ) -> torch.Tensor:
280
+ """Batched version of ``blocks_to_layout_flat``.
281
+
282
+ Args:
283
+ block_flat_batch: 2D tensor of shape ``(N, total_block_flat_size)``.
284
+ blocks: List of BlockConfig with ``leaf_indices`` into ``layout.entries``.
285
+ layout: ParamLayout for the model.
286
+
287
+ Returns:
288
+ 2D tensor of shape ``(N, layout.padded_size)``.
289
+ """
290
+ N = block_flat_batch.shape[0]
291
+ layout_batch = torch.zeros(
292
+ N,
293
+ layout.padded_size,
294
+ dtype=block_flat_batch.dtype,
295
+ device=block_flat_batch.device,
296
+ )
297
+
298
+ for block in blocks:
299
+ internal_offset = 0
300
+ for leaf_idx in block.leaf_indices:
301
+ entry = layout.entries[leaf_idx]
302
+ numel = entry.numel
303
+ layout_batch[:, entry.offset : entry.offset + numel] = block_flat_batch[
304
+ :, block.flat_start + internal_offset : block.flat_start + internal_offset + numel
305
+ ]
306
+ internal_offset += numel
307
+
308
+ return layout_batch
309
+
310
+
311
+ # ------------------------------------------------------------------
312
+ # Subspace-aware block functions
313
+ # ------------------------------------------------------------------
314
+
315
+
316
+ def create_subspace_blocks(
317
+ subspace_dim: int,
318
+ num_blocks: int,
319
+ subspace_particle_dim: int = 8,
320
+ ) -> List[BlockConfig]:
321
+ """Create blocks that divide the subspace coordinate space.
322
+
323
+ For combined subspace + block-wise mode, the OT decomposition operates
324
+ in the projected subspace coordinates, NOT in full parameter space.
325
+ This function divides the subspace_dim coordinates into num_blocks
326
+ equal-sized blocks, each with subspace_particle_dim as the particle
327
+ dimension.
328
+
329
+ Key design insight:
330
+ - Block operation space is PROJECTED SUBSPACE, not full parameter space
331
+ - Blocks slice the subspace_dim coordinates
332
+ - Global projection P is shared across all blocks (applied once at start/end)
333
+
334
+ Args:
335
+ subspace_dim: Total subspace dimension (e.g., 256).
336
+ num_blocks: Number of blocks to create.
337
+ subspace_particle_dim: Particle dimension within each block (default 8).
338
+ Higher values give more polytope vertices but fewer particles per block.
339
+
340
+ Returns:
341
+ List of BlockConfig for subspace-aware block decomposition.
342
+
343
+ Example::
344
+
345
+ # 256-dim subspace split into 4 blocks with 8-dim particles
346
+ blocks = create_subspace_blocks(256, num_blocks=4, subspace_particle_dim=8)
347
+ # Each block: 64 subspace coords -> 8 particles of dim 8
348
+
349
+ """
350
+ # Pad subspace_dim to be divisible by subspace_particle_dim
351
+ padded_subspace = subspace_dim + (-subspace_dim % subspace_particle_dim)
352
+ total_particles = padded_subspace // subspace_particle_dim
353
+
354
+ if num_blocks > total_particles:
355
+ warnings.warn(
356
+ f"num_blocks ({num_blocks}) exceeds total_particles ({total_particles}). Clamping to total_particles.",
357
+ stacklevel=2,
358
+ )
359
+ num_blocks = total_particles
360
+
361
+ # Divide particles evenly across blocks
362
+ base_particles_per_block = total_particles // num_blocks
363
+ remainder = total_particles % num_blocks
364
+
365
+ blocks: List[BlockConfig] = []
366
+ offset = 0
367
+
368
+ for i in range(num_blocks):
369
+ # Distribute remainder: first 'remainder' blocks get one extra particle
370
+ num_particles = base_particles_per_block + (1 if i < remainder else 0)
371
+ flat_size = num_particles * subspace_particle_dim
372
+ blocks.append(
373
+ BlockConfig(
374
+ name=f"subspace_block_{i}",
375
+ leaf_indices=(), # Not used for subspace blocks
376
+ flat_start=offset,
377
+ flat_end=offset + flat_size,
378
+ num_particles=num_particles,
379
+ particle_dim=subspace_particle_dim,
380
+ )
381
+ )
382
+ offset += flat_size
383
+
384
+ return blocks
385
+
386
+
387
+ def split_subspace_to_blocks(
388
+ subspace_coords: torch.Tensor,
389
+ blocks: List[BlockConfig],
390
+ ) -> List[torch.Tensor]:
391
+ """Split 1D subspace coordinate vector into per-block particle tensors.
392
+
393
+ Takes a flattened subspace coordinate vector and slices it according to
394
+ the block configuration. Each slice is reshaped to (num_particles, particle_dim).
395
+
396
+ This is the subspace-aware equivalent of split_particles(), operating
397
+ on subspace coordinates rather than full parameter flat vectors.
398
+
399
+ Args:
400
+ subspace_coords: 1D tensor of shape (subspace_dim,) or 2D tensor of
401
+ shape (num_particles, particle_dim) that will be flattened.
402
+ blocks: List of BlockConfig from create_subspace_blocks().
403
+
404
+ Returns:
405
+ List of tensors, each of shape (block.num_particles, block.particle_dim).
406
+
407
+ Example::
408
+
409
+ coords = torch.randn(256) # subspace coordinates
410
+ blocks = create_subspace_blocks(256, 4, 8)
411
+ block_particles = split_subspace_to_blocks(coords, blocks)
412
+ # block_particles[0].shape == (8, 8) # 64 coords -> 8 particles x 8 dim
413
+
414
+ """
415
+ flat = subspace_coords.reshape(-1)
416
+
417
+ # Pad to total block size if subspace_dim isn't divisible by particle_dim
418
+ total_block_size = blocks[-1].flat_end if blocks else 0
419
+ if flat.shape[0] < total_block_size:
420
+ flat = torch.nn.functional.pad(flat, (0, total_block_size - flat.shape[0]))
421
+
422
+ result: List[torch.Tensor] = []
423
+
424
+ for block in blocks:
425
+ block_flat = flat[block.flat_start : block.flat_end]
426
+ result.append(block_flat.reshape(block.num_particles, block.particle_dim))
427
+
428
+ return result
429
+
430
+
431
+ def reassemble_blocks_to_subspace(
432
+ block_particles: List[torch.Tensor],
433
+ blocks: List[BlockConfig],
434
+ subspace_dim: int,
435
+ ) -> torch.Tensor:
436
+ """Reconstruct 1D subspace coordinate vector from per-block particle tensors.
437
+
438
+ Inverse of split_subspace_to_blocks(). Takes the list of per-block particle
439
+ tensors and reassembles them into a contiguous subspace coordinate vector.
440
+
441
+ Args:
442
+ block_particles: List of tensors, each of shape
443
+ (block.num_particles, block.particle_dim).
444
+ blocks: List of BlockConfig from create_subspace_blocks().
445
+ subspace_dim: Original subspace dimension (for trimming padding).
446
+
447
+ Returns:
448
+ 1D tensor of shape (subspace_dim,).
449
+
450
+ Example::
451
+
452
+ # After per-block OT updates
453
+ updated_coords = reassemble_blocks_to_subspace(
454
+ block_particles, blocks, subspace_dim=256
455
+ )
456
+
457
+ """
458
+ if not block_particles:
459
+ return torch.zeros(subspace_dim)
460
+
461
+ # Total padded size from blocks
462
+ total_padded = sum(b.flat_end - b.flat_start for b in blocks)
463
+ device = block_particles[0].device
464
+ dtype = block_particles[0].dtype
465
+
466
+ full_flat = torch.zeros(total_padded, dtype=dtype, device=device)
467
+
468
+ for block_X, block in zip(block_particles, blocks):
469
+ block_flat = block_X.reshape(-1)
470
+ full_flat[block.flat_start : block.flat_end] = block_flat
471
+
472
+ # Trim to actual subspace_dim (remove padding)
473
+ return full_flat[:subspace_dim]
474
+
475
+
476
+ def compute_block_cost_matrix(
477
+ block_idx: int,
478
+ X_probe_block: torch.Tensor,
479
+ all_block_particles: List[torch.Tensor],
480
+ blocks: List[BlockConfig],
481
+ layout: ParamLayout,
482
+ evaluator: NNCostEvaluator,
483
+ inputs: torch.Tensor,
484
+ targets: Optional[torch.Tensor] = None,
485
+ ) -> torch.Tensor:
486
+ """Compute cost matrix for one block: full forward, perturb only this block.
487
+
488
+ For each probe point (p, v, k), constructs full model parameters by
489
+ taking the current particles from all blocks and replacing particle row p
490
+ in block_idx with the probe value. This captures cross-block interactions
491
+ through the full model forward pass.
492
+
493
+ Args:
494
+ block_idx: Index of the block being evaluated.
495
+ X_probe_block: Probe points for this block, shape
496
+ ``(P, V, K, block_particle_dim)``.
497
+ all_block_particles: Current particles for all blocks (base values).
498
+ blocks: List of all BlockConfig.
499
+ layout: ParamLayout for converting flat vectors to param dicts.
500
+ evaluator: NNCostEvaluator for model evaluation.
501
+ inputs: Input data batch.
502
+ targets: Optional target labels.
503
+
504
+ Returns:
505
+ Cost matrix of shape ``(P, V)``.
506
+ """
507
+ P, V, K, D = X_probe_block.shape
508
+ N = P * V * K
509
+ total_flat_size = sum(b.flat_end - b.flat_start for b in blocks)
510
+
511
+ # Build base full flat vector from current block particles
512
+ base_flat = reassemble_blocks(all_block_particles, blocks, total_flat_size)
513
+
514
+ # Expand base flat to batch: (N, total_flat_size)
515
+ base_batch = base_flat.unsqueeze(0).expand(N, -1).clone()
516
+
517
+ # For each probe point (p, v, k), replace particle row p in block_idx
518
+ block = blocks[block_idx]
519
+ flat_probes = X_probe_block.reshape(P * V * K, D) # (N, D)
520
+
521
+ # Each probe n corresponds to particle p = n // (V*K)
522
+ # The flat offset for particle p in the block is:
523
+ # block.flat_start + p * D
524
+ # Vectorized scatter: compute all row offsets at once
525
+ indices = torch.arange(N, device=flat_probes.device)
526
+ p_indices = indices // (V * K)
527
+ row_starts = block.flat_start + p_indices * D
528
+ # Build column indices for each of the D elements per probe
529
+ col_offsets = torch.arange(D, device=flat_probes.device) # (D,)
530
+ # (N, D) matrix of column indices into base_batch
531
+ col_indices = row_starts.unsqueeze(1) + col_offsets.unsqueeze(0)
532
+ # Scatter flat_probes into base_batch using advanced indexing
533
+ row_idx = indices.unsqueeze(1).expand(-1, D) # (N, D)
534
+ base_batch[row_idx, col_indices] = flat_probes
535
+
536
+ # Per-layer blocks pad each entry independently, creating different
537
+ # offsets from ParamLayout (which concatenates entries contiguously).
538
+ # Map from block offsets to layout offsets for correct batch_unflatten.
539
+ batch_for_layout = blocks_to_layout_flat_batch(base_batch, blocks, layout)
540
+
541
+ # Convert to stacked param dicts
542
+ stacked_params = layout.batch_unflatten(batch_for_layout)
543
+
544
+ # Evaluate all probe points
545
+ losses = evaluator.evaluate(stacked_params, inputs, targets) # (N,)
546
+
547
+ # Reshape and average over probe dimension K
548
+ cost_matrix = losses.reshape(P, V, K).mean(dim=-1) # (P, V)
549
+ return cost_matrix