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/cost_nn.py ADDED
@@ -0,0 +1,633 @@
1
+ """Vectorized NN cost evaluation via vmap + functional_call.
2
+
3
+ Evaluates a neural network at batched parameter configurations using
4
+ ``torch.vmap`` for vectorized evaluation -- each candidate parameter set
5
+ is evaluated in parallel by swapping model parameters via
6
+ ``torch.func.functional_call``. Falls back to a sequential Python loop
7
+ if vmap is incompatible with the model (one-time warning emitted).
8
+
9
+ When ``chunk_size`` is set, evaluations are batched to bound peak GPU
10
+ memory. ``auto_detect_chunk_size`` estimates a safe value based on model
11
+ size and available GPU memory.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+ from typing import Callable, Optional, TYPE_CHECKING, Union
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ from torch.func import functional_call, vmap
22
+
23
+ if TYPE_CHECKING:
24
+ from .hybrid_subspace import HybridSubspace
25
+ from .transform import ParamLayout
26
+
27
+
28
+ def auto_detect_chunk_size(
29
+ model: nn.Module,
30
+ safety_factor: float = 2.0,
31
+ compile_overhead: bool = False,
32
+ ) -> Optional[int]:
33
+ """Estimate safe vmap chunk_size from model size and GPU memory.
34
+
35
+ Returns None when the model is on CPU (no memory limit needed),
36
+ even if the machine has a GPU available. On GPU, estimates
37
+ per-evaluation memory as 4x parameter memory (conservative
38
+ heuristic for activations + intermediates) and divides available
39
+ GPU memory by this estimate.
40
+
41
+ Args:
42
+ model: The model to estimate for.
43
+ safety_factor: Divisor for extra safety margin (default 2.0).
44
+ compile_overhead: When True, multiply the safety factor by 1.5
45
+ to account for the 10-20% extra peak memory ``torch.compile``
46
+ pulls in for CUDA graph capture.
47
+
48
+ Returns:
49
+ Recommended chunk_size, or None if model is on CPU.
50
+ """
51
+ # Check actual device of model parameters, not global CUDA availability
52
+ try:
53
+ param_device = next(model.parameters()).device
54
+ except StopIteration:
55
+ return None # No parameters
56
+
57
+ if param_device.type != "cuda":
58
+ return None
59
+
60
+ param_bytes = sum(p.numel() * p.element_size() for p in model.parameters())
61
+ buffer_bytes = sum(b.numel() * b.element_size() for b in model.buffers())
62
+ # Heuristic: 4x (params + buffers) for activations + intermediates
63
+ per_eval_bytes = (param_bytes + buffer_bytes) * 4
64
+
65
+ if per_eval_bytes <= 0:
66
+ return None
67
+
68
+ # torch.compile reserves CUDA-graph workspaces and intermediate
69
+ # buffers that push peak memory ~10-20% above the eager path; bake
70
+ # the headroom into the safety factor so we don't OOM at first call.
71
+ effective_safety = safety_factor * 1.5 if compile_overhead else safety_factor
72
+
73
+ free_mem, _ = torch.cuda.mem_get_info(param_device)
74
+ chunk = max(1, int(free_mem / (per_eval_bytes * effective_safety)))
75
+ return chunk
76
+
77
+
78
+ _UNSET = object() # sentinel distinguishing "not yet computed" from None
79
+
80
+
81
+ def _is_vanilla_cross_entropy(loss_fn: nn.CrossEntropyLoss) -> bool:
82
+ """True when a CrossEntropyLoss uses default reduction and no reweighting.
83
+
84
+ The batched-linear fast path reimplements cross-entropy with mean reduction
85
+ and no class weights, so it is only correct for an unconfigured loss. Weighted
86
+ classes, label smoothing, a custom ignore_index, or a non-mean reduction must
87
+ fall back to the vmap path, which calls the real loss_fn.
88
+ """
89
+ return (
90
+ loss_fn.weight is None
91
+ and loss_fn.ignore_index == -100
92
+ and float(loss_fn.label_smoothing) == 0.0
93
+ and loss_fn.reduction == "mean"
94
+ )
95
+
96
+
97
+ class NNCostEvaluator:
98
+ """Vectorized NN cost evaluation via vmap + functional_call.
99
+
100
+ Evaluates a model at N batched parameter configurations. Uses
101
+ ``torch.vmap`` for vectorized inference; falls back to a sequential
102
+ Python loop if vmap fails (one-time warning emitted).
103
+
104
+ Args:
105
+ model: The ``nn.Module`` to evaluate. Will be put in eval mode.
106
+ loss_fn: Loss function with signature:
107
+
108
+ - ``loss_fn(output, targets) -> scalar`` (supervised), or
109
+ - ``loss_fn(output) -> scalar`` (unsupervised, targets=None).
110
+ chunk_size: vmap ``chunk_size`` for memory control.
111
+ ``None`` = evaluate all at once (no chunking).
112
+ ``"auto"`` = auto-detect from model size and GPU memory.
113
+ Positive int = evaluate in chunks of this size.
114
+ compile_vmap: If True, wrap the vmap evaluation in
115
+ ``torch.compile(mode="default")`` for kernel fusion. Falls back
116
+ to eager on failure. Best for CUDA models. Default False.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ model: nn.Module,
122
+ loss_fn: Callable,
123
+ chunk_size: Union[None, int, str] = None,
124
+ compile_vmap: bool = False,
125
+ use_inplace: Optional[bool] = None,
126
+ ):
127
+ self.model = model
128
+ self.loss_fn = loss_fn
129
+ self._chunk_size_raw = chunk_size
130
+ self._chunk_size_cached = _UNSET # lazily computed for "auto" mode
131
+ self._vmap_failed = False
132
+ self._warned = False
133
+ self._compile_vmap = compile_vmap
134
+ self._compiled_vmap_fn = None
135
+ self._compile_failed = False
136
+
137
+ # Force eval mode for consistent behavior (frozen BN stats, no dropout)
138
+ model.eval()
139
+
140
+ # Try to build fast batched-linear evaluator (MLP-only models).
141
+ # Only for a default CrossEntropyLoss: the bmm path hardcodes mean
142
+ # reduction with no class weights, so a configured loss (weights, label
143
+ # smoothing, custom ignore_index, non-mean reduction) must use vmap.
144
+ if isinstance(loss_fn, nn.CrossEntropyLoss) and _is_vanilla_cross_entropy(loss_fn):
145
+ self._batched_linear = BatchedLinearEvaluator.try_build(model, loss_fn)
146
+ else:
147
+ self._batched_linear = None
148
+
149
+ # Auto-detect whether to use memory-efficient in-place evaluation.
150
+ # For very large models (>500K params) on GPU, vmap materializes N
151
+ # copies of all intermediate activations simultaneously, causing
152
+ # O(N x activation) memory. In-place evaluation uses O(1 x activation)
153
+ # regardless of N. For models ≤500K params (e.g. MNISTNet ~102K,
154
+ # CIFAR10MLP ~199K), chunked vmap is fast and fits in GPU memory.
155
+ # Pass use_inplace=True/False to override auto-detection.
156
+ if use_inplace is not None:
157
+ self._use_inplace = use_inplace
158
+ else:
159
+ n_params = sum(p.numel() for p in model.parameters())
160
+ try:
161
+ on_gpu = next(model.parameters()).device.type == "cuda"
162
+ except StopIteration:
163
+ on_gpu = False
164
+ self._use_inplace = on_gpu and n_params > 500_000
165
+
166
+ # Cache frozen buffers from the real model (shared across all particles)
167
+ self._buffers = dict(model.named_buffers())
168
+
169
+ # Cache param dict for in-place evaluation (avoids O(L) module traversal per call)
170
+ self._param_dict_cache = dict(self.model.named_parameters())
171
+
172
+ def reset_vmap(self) -> None:
173
+ """Reset the vmap failure flag so vmap is attempted again.
174
+
175
+ Useful after changing the model architecture (e.g., swapping layers),
176
+ moving the model to a different device, or upgrading PyTorch (vmap
177
+ op coverage expands across releases).
178
+ """
179
+ self._vmap_failed = False
180
+ self._warned = False
181
+
182
+ @property
183
+ def chunk_size(self) -> Optional[int]:
184
+ """Resolved chunk_size for vmap.
185
+
186
+ Returns None (no chunking), or a positive int. When
187
+ ``chunk_size="auto"`` was passed, queries GPU memory once to compute
188
+ a safe value (returns None on CPU), then caches the result.
189
+ """
190
+ if self._chunk_size_raw == "auto":
191
+ if self._chunk_size_cached is _UNSET:
192
+ self._chunk_size_cached = auto_detect_chunk_size(
193
+ self.model,
194
+ compile_overhead=self._compile_vmap,
195
+ )
196
+ return self._chunk_size_cached
197
+ return self._chunk_size_raw
198
+
199
+ @torch.inference_mode()
200
+ def evaluate(
201
+ self,
202
+ stacked_params: dict[str, torch.Tensor],
203
+ inputs: torch.Tensor,
204
+ targets: Optional[torch.Tensor] = None,
205
+ ) -> torch.Tensor:
206
+ """Evaluate model at batched parameters.
207
+
208
+ Args:
209
+ stacked_params: ``{key: (N, *param_shape)}`` stacked param dicts
210
+ (from ``ParamLayout.batch_unflatten()``).
211
+ inputs: Input data batch (broadcast to all N evaluations).
212
+ targets: Optional targets (broadcast to all N evaluations).
213
+
214
+ Returns:
215
+ Losses tensor of shape ``(N,)``.
216
+ """
217
+ # Model should already be in eval mode (set in __init__).
218
+ # Fast path: was_training is False 99.9% of the time (single bool check,
219
+ # no O(L) module traversal). Restore on exit for external callers.
220
+ was_training = self.model.training
221
+ if was_training:
222
+ self.model.eval()
223
+
224
+ # Match float inputs to the (maybe bf16) param dtype so bmm/vmap don't hit a
225
+ # dtype mismatch under mixed precision. Integer inputs and targets untouched.
226
+ if stacked_params:
227
+ param_dtype = next(iter(stacked_params.values())).dtype
228
+ if inputs.is_floating_point() and inputs.dtype != param_dtype:
229
+ inputs = inputs.to(param_dtype)
230
+
231
+ try:
232
+ # Fast path: batched bmm for Linear-only models (MLP)
233
+ # Only used for supervised (targets != None) with cross-entropy.
234
+ if self._batched_linear is not None and targets is not None:
235
+ return self._batched_linear.evaluate(stacked_params, inputs, targets)
236
+
237
+ # Memory-efficient path: in-place weight swap for large GPU models.
238
+ # Uses O(1 x activation) memory instead of O(N x activation).
239
+ if self._use_inplace:
240
+ return self._evaluate_inplace(stacked_params, inputs, targets)
241
+
242
+ if self._vmap_failed:
243
+ result = self._evaluate_loop(stacked_params, inputs, targets)
244
+ else:
245
+ try:
246
+ result = self._evaluate_vmap(stacked_params, inputs, targets)
247
+ except Exception as e:
248
+ # Only catch vmap/functorch-related errors; re-raise real bugs
249
+ msg = str(e).lower()
250
+ is_vmap_issue = any(
251
+ k in msg for k in ("vmap", "functorch", "batched tensor", "batched", "randomness")
252
+ )
253
+ if not is_vmap_issue:
254
+ raise
255
+ if not self._warned:
256
+ warnings.warn(
257
+ f"vmap failed for {type(self.model).__name__}: {e}. Falling back to sequential evaluation.",
258
+ stacklevel=2,
259
+ )
260
+ self._warned = True
261
+ self._vmap_failed = True
262
+ result = self._evaluate_loop(stacked_params, inputs, targets)
263
+ return result
264
+ finally:
265
+ if was_training:
266
+ self.model.train()
267
+
268
+ def _evaluate_vmap(self, stacked_params, inputs, targets):
269
+ """Vectorized evaluation via vmap + functional_call.
270
+
271
+ When ``compile_vmap=True`` (set at init), wraps the batched evaluation
272
+ in ``torch.compile(mode="reduce-overhead")`` for kernel fusion and
273
+ CUDA graph capture. Falls back to eager vmap on compilation failure.
274
+ """
275
+ buffers = self._buffers
276
+ loss_fn = self.loss_fn
277
+ model = self.model
278
+ resolved_chunk = self.chunk_size
279
+
280
+ # inputs/targets are explicit args (in_dims=None), not closed-over, so a
281
+ # cached torch.compile graph does not bake in the first call's batch.
282
+ def single_eval(params, inputs, targets):
283
+ # Buffers override params intentionally: stacked_params contains only
284
+ # trainable entries from ParamLayout, while self._buffers holds frozen
285
+ # model state (e.g., BatchNorm running_mean/var, num_batches_tracked).
286
+ # If a key appears in both, the buffer value is authoritative because
287
+ # buffers are not part of the OT optimization and must stay frozen.
288
+ full_dict = {**params, **buffers}
289
+ output = functional_call(model, full_dict, (inputs,))
290
+ if targets is not None:
291
+ loss = loss_fn(output, targets)
292
+ else:
293
+ loss = loss_fn(output)
294
+ if loss.dim() > 0:
295
+ loss = loss.mean()
296
+ return loss
297
+
298
+ batched = vmap(single_eval, in_dims=(0, None, None), chunk_size=resolved_chunk)
299
+
300
+ # Compiled path: torch.compile on vmap for kernel fusion + CUDA graphs.
301
+ # Only attempted on CUDA with compile_vmap=True. Lazy-compiled on first call.
302
+ if self._compile_vmap and not self._compile_failed:
303
+ if self._compiled_vmap_fn is None:
304
+ try:
305
+ # Use "default" mode: kernel fusion without CUDA graphs.
306
+ # "reduce-overhead" (CUDA graphs) has tensor ownership conflicts
307
+ # with vmap's chunked output concatenation.
308
+ self._compiled_vmap_fn = torch.compile(
309
+ batched,
310
+ mode="default",
311
+ fullgraph=False,
312
+ )
313
+ except Exception:
314
+ self._compile_failed = True
315
+ self._compiled_vmap_fn = None
316
+
317
+ if self._compiled_vmap_fn is not None:
318
+ try:
319
+ return self._compiled_vmap_fn(stacked_params, inputs, targets)
320
+ except Exception:
321
+ # Compilation or execution failed - fall back to eager permanently
322
+ self._compile_failed = True
323
+ self._compiled_vmap_fn = None
324
+
325
+ return batched(stacked_params, inputs, targets)
326
+
327
+ def _evaluate_loop(self, stacked_params, inputs, targets):
328
+ """Sequential fallback when vmap is incompatible."""
329
+ if not stacked_params:
330
+ return torch.zeros(0, device=inputs.device)
331
+ N = next(iter(stacked_params.values())).shape[0]
332
+ if N == 0:
333
+ return torch.zeros(0, device=inputs.device)
334
+ losses = []
335
+ for i in range(N):
336
+ params_i = {k: v[i] for k, v in stacked_params.items()}
337
+ full_dict = {**params_i, **self._buffers}
338
+ output = functional_call(self.model, full_dict, (inputs,))
339
+ if targets is not None:
340
+ loss = self.loss_fn(output, targets)
341
+ else:
342
+ loss = self.loss_fn(output)
343
+ if loss.dim() > 0:
344
+ loss = loss.mean()
345
+ losses.append(loss)
346
+ return torch.stack(losses)
347
+
348
+ def _evaluate_inplace(self, stacked_params, inputs, targets):
349
+ """Memory-minimal evaluation via in-place weight swapping.
350
+
351
+ Evaluates N parameter configurations sequentially by directly
352
+ modifying model weights in-place. Uses only O(1 x activation)
353
+ memory regardless of N, making it feasible for large models
354
+ where vmap would OOM.
355
+
356
+ Inspired by MeZO's in-place perturbation strategy and ZO2's
357
+ sequential evaluation. Uses ``torch.inference_mode()`` to
358
+ eliminate autograd overhead and view tracking.
359
+
360
+ Wall-clock: ~Nx slower than vmap for small models, but for
361
+ large models where each forward pass already saturates the GPU,
362
+ the overhead is minimal (~2x vs vmap).
363
+ """
364
+ if not stacked_params:
365
+ return torch.zeros(0, device=inputs.device)
366
+ N = next(iter(stacked_params.values())).shape[0]
367
+ device = inputs.device
368
+ losses = torch.empty(N, device=device)
369
+
370
+ # Save original weights (one copy, regardless of N)
371
+ original_params = {}
372
+ param_dict = self._param_dict_cache
373
+ if not stacked_params.keys() <= param_dict.keys():
374
+ # Model gained or renamed params since construction; refresh the cache
375
+ # so new keys aren't silently evaluated with stale weights.
376
+ param_dict = self._param_dict_cache = dict(self.model.named_parameters())
377
+ for key in stacked_params:
378
+ if key in param_dict:
379
+ original_params[key] = param_dict[key].data.clone()
380
+
381
+ try:
382
+ for i in range(N):
383
+ # Swap weights in-place - no copies, just overwrite .data
384
+ for key in stacked_params:
385
+ if key in param_dict:
386
+ param_dict[key].data.copy_(stacked_params[key][i])
387
+
388
+ # Forward pass - already under inference_mode from evaluate()
389
+ output = self.model(inputs)
390
+ if targets is not None:
391
+ loss = self.loss_fn(output, targets)
392
+ else:
393
+ loss = self.loss_fn(output)
394
+ if loss.dim() > 0:
395
+ loss = loss.mean()
396
+ # .item()-free: store tensor directly, detach from graph
397
+ losses[i] = loss.detach()
398
+ finally:
399
+ # Always restore original weights, even on error
400
+ for key, orig in original_params.items():
401
+ if key in param_dict:
402
+ param_dict[key].data.copy_(orig)
403
+
404
+ return losses
405
+
406
+ def evaluate_subspace_inplace(
407
+ self,
408
+ subspace: "HybridSubspace",
409
+ projections: dict,
410
+ base_sd: dict[str, torch.Tensor],
411
+ flat_subspace_batch: torch.Tensor,
412
+ inputs: torch.Tensor,
413
+ targets: Optional[torch.Tensor] = None,
414
+ ) -> torch.Tensor:
415
+ """Fused subspace-reconstruct + forward via in-place weight swap.
416
+
417
+ Instead of materialising all ``N`` configurations' full weights
418
+ up front via ``reconstruct_batch`` and then evaluating, this
419
+ method reconstructs one configuration at a time directly into the
420
+ model parameters using ``apply_perturbation_inplace`` and runs a
421
+ single forward pass per configuration.
422
+
423
+ Peak memory is ``O(model_params + batch * activation)``
424
+ independent of ``N``; no stacked parameter dict is ever allocated.
425
+
426
+ Args:
427
+ subspace: HybridSubspace with ``apply_perturbation_inplace``.
428
+ projections: Per-layer projection matrices.
429
+ base_sd: Base (unperturbed) state_dict.
430
+ flat_subspace_batch: (N, subspace_dim) subspace coordinates.
431
+ inputs: Input data batch.
432
+ targets: Optional targets.
433
+
434
+ Returns:
435
+ Losses tensor of shape (N,).
436
+ """
437
+ N = flat_subspace_batch.shape[0]
438
+ device = inputs.device
439
+ losses = torch.empty(N, device=device)
440
+
441
+ was_training = self.model.training
442
+ if was_training:
443
+ self.model.eval()
444
+
445
+ try:
446
+ for i in range(N):
447
+ # Reconstruct weights for config i directly into model params
448
+ subspace.apply_perturbation_inplace(
449
+ projections,
450
+ self.model,
451
+ base_sd,
452
+ flat_subspace_batch[i],
453
+ )
454
+ # Forward pass - already under inference_mode from caller
455
+ output = self.model(inputs)
456
+ if targets is not None:
457
+ loss = self.loss_fn(output, targets)
458
+ else:
459
+ loss = self.loss_fn(output)
460
+ if loss.dim() > 0:
461
+ loss = loss.mean()
462
+ losses[i] = loss.detach()
463
+ finally:
464
+ # Restore base weights
465
+ param_dict = dict(self.model.named_parameters())
466
+ for key, base in base_sd.items():
467
+ if key in param_dict:
468
+ param_dict[key].data.copy_(base)
469
+ if was_training:
470
+ self.model.train()
471
+
472
+ return losses
473
+
474
+
475
+ class BatchedLinearEvaluator:
476
+ """Fast batched evaluation for pure-MLP models.
477
+
478
+ Replaces vmap + functional_call with explicit ``torch.bmm`` per Linear
479
+ layer, eliminating vmap dispatch overhead. For N parameter configs of a
480
+ k-layer MLP, performs k batched matmuls instead of N sequential forward
481
+ passes or a single vmap call.
482
+
483
+ Supported layers: ``nn.Linear``, ``nn.ReLU``, ``nn.LeakyReLU``,
484
+ ``nn.Sigmoid``, ``nn.Tanh``, ``nn.GELU``, ``nn.SiLU``, ``nn.Flatten``,
485
+ and ``nn.Dropout`` (eval mode). Models containing any other layer
486
+ cause ``try_build`` to return ``None``.
487
+ """
488
+
489
+ def __init__(self, model: nn.Module, loss_fn: Callable, layer_keys: list):
490
+ self.model = model
491
+ self.loss_fn = loss_fn
492
+ self._layer_keys = layer_keys # ordered list of (name, type_tag)
493
+
494
+ @classmethod
495
+ def try_build(cls, model: nn.Module, loss_fn: Callable) -> "BatchedLinearEvaluator | None":
496
+ """Build if model is compatible, else return None."""
497
+ # The bmm plan is rebuilt from named_children(), which misses activations
498
+ # applied inline in a custom forward (e.g. torch.relu) and would compute a
499
+ # wrong, activation-free loss. Only trust genuine nn.Sequential forwards.
500
+ if type(model).forward is not nn.Sequential.forward:
501
+ return None
502
+ supported = (nn.Linear, nn.ReLU, nn.LeakyReLU, nn.Sigmoid, nn.Tanh, nn.GELU, nn.SiLU, nn.Flatten, nn.Dropout)
503
+
504
+ # Build an ordered (name, tag, module) plan. ``module`` is None for
505
+ # Linear layers (handled by bmm) and the actual nn.Module for
506
+ # activations so evaluate() applies their EXACT semantics
507
+ # (LeakyReLU.negative_slope, GELU.approximate, ...) instead of
508
+ # hardcoded functional defaults. Unsupported -> None (vmap fallback).
509
+ def _entry(full, submod):
510
+ if isinstance(submod, nn.Linear):
511
+ return (full, "linear", None)
512
+ # A non-default Flatten reshapes differently from the bmm path's
513
+ # leading flatten; defer those models to the (correct) vmap path.
514
+ if isinstance(submod, nn.Flatten) and (submod.start_dim, submod.end_dim) != (1, -1):
515
+ return None
516
+ if isinstance(submod, supported):
517
+ return (full, type(submod).__name__.lower(), submod)
518
+ return None
519
+
520
+ layer_keys = []
521
+ for name, mod in model.named_children():
522
+ if isinstance(mod, nn.Sequential):
523
+ # Walk one level of Sequential
524
+ children = [(f"{name}.{sub}", m) for sub, m in mod.named_children()]
525
+ else:
526
+ children = [(name, mod)]
527
+ for full, submod in children:
528
+ entry = _entry(full, submod)
529
+ if entry is None:
530
+ return None # unsupported layer (Conv2d, non-default Flatten, ...)
531
+ layer_keys.append(entry)
532
+ if not layer_keys:
533
+ return None
534
+ # Verify all named parameters are covered by detected Linear layers.
535
+ # Models with extra parameters (e.g., learned scales) need vmap.
536
+ linear_param_keys = set()
537
+ for name, tag, _ in layer_keys:
538
+ if tag == "linear":
539
+ linear_param_keys.add(f"{name}.weight")
540
+ linear_param_keys.add(f"{name}.bias")
541
+ model_param_keys = {n for n, _ in model.named_parameters()}
542
+ if not model_param_keys.issubset(linear_param_keys):
543
+ return None
544
+ return cls(model, loss_fn, layer_keys)
545
+
546
+ @torch.inference_mode()
547
+ def evaluate(
548
+ self,
549
+ stacked_params: dict[str, torch.Tensor],
550
+ inputs: torch.Tensor,
551
+ targets: torch.Tensor | None = None,
552
+ ) -> torch.Tensor:
553
+ """Batched forward via bmm for Linear layers."""
554
+ N = next(iter(stacked_params.values())).shape[0]
555
+ # x: (N, B, features) - expand input across N param configs
556
+ if inputs.dim() == 2:
557
+ x = inputs.unsqueeze(0).expand(N, -1, -1) # (N, B, in_feat)
558
+ else:
559
+ # Flatten spatial dims for non-2D inputs
560
+ x = inputs.reshape(inputs.shape[0], -1).unsqueeze(0).expand(N, -1, -1)
561
+
562
+ for name, tag, module in self._layer_keys:
563
+ if tag == "linear":
564
+ w_key = f"{name}.weight"
565
+ b_key = f"{name}.bias"
566
+ W = stacked_params[w_key] # (N, out, in)
567
+ # bmm: (N, B, in) @ (N, in, out) -> (N, B, out)
568
+ x = torch.bmm(x, W.transpose(1, 2)) # (N, B, out)
569
+ if b_key in stacked_params:
570
+ x = x + stacked_params[b_key].unsqueeze(1) # (N, 1, out) broadcast
571
+ elif tag in ("flatten", "dropout"):
572
+ pass # input is pre-flattened; dropout is identity in eval mode
573
+ else:
574
+ # Activation: apply the real module so its configuration
575
+ # (LeakyReLU negative_slope, GELU approximate, ...) is exact.
576
+ x = module(x)
577
+
578
+ # x: (N, B, out_features) - compute per-config loss
579
+ if targets is not None:
580
+ tgt = targets.unsqueeze(0).expand(N, -1) # (N, B)
581
+ # Reshape for cross-entropy: (N*B, C) vs (N*B,)
582
+ losses = (
583
+ torch.nn.functional.cross_entropy(
584
+ x.reshape(N * targets.shape[0], -1),
585
+ tgt.reshape(-1),
586
+ reduction="none",
587
+ )
588
+ .reshape(N, -1)
589
+ .mean(dim=1)
590
+ ) # (N,)
591
+ else:
592
+ losses = self.loss_fn(x).mean(dim=1) if x.dim() > 2 else self.loss_fn(x)
593
+ return losses
594
+
595
+
596
+ def compute_nn_cost_matrix(
597
+ evaluator: NNCostEvaluator,
598
+ X_probe: torch.Tensor,
599
+ layout: ParamLayout,
600
+ inputs: torch.Tensor,
601
+ targets: Optional[torch.Tensor] = None,
602
+ ) -> torch.Tensor:
603
+ """Compute NN cost matrix from probe points via vectorized evaluation.
604
+
605
+ Flattens the (P, V, K) probe structure into a single batch dimension,
606
+ evaluates all probe points via the evaluator, then reshapes and averages
607
+ over the probe dimension K to produce the (P, V) cost matrix.
608
+
609
+ Args:
610
+ evaluator: NNCostEvaluator instance.
611
+ X_probe: Probe points of shape ``(P, V, K, D)`` where D is the
612
+ particle flat dimension (rows * particle_dim).
613
+ layout: ParamLayout for converting particles to param dicts.
614
+ inputs: Input data batch (shared across all evaluations).
615
+ targets: Optional targets (shared across all evaluations).
616
+
617
+ Returns:
618
+ Cost matrix of shape ``(P, V)``.
619
+ """
620
+ P, V, K, D = X_probe.shape
621
+
622
+ # Flatten (P, V, K) into single batch dimension N = P*V*K
623
+ flat_probes = X_probe.reshape(P * V * K, D)
624
+
625
+ # Convert to stacked param dicts: {key: (N, *param_shape)}
626
+ stacked_params = layout.batch_unflatten(flat_probes)
627
+
628
+ # Evaluate all probe points
629
+ losses = evaluator.evaluate(stacked_params, inputs, targets) # (N,)
630
+
631
+ # Reshape and average over probe dimension K
632
+ cost_matrix = losses.reshape(P, V, K).mean(dim=-1) # (P, V)
633
+ return cost_matrix