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
@@ -0,0 +1,260 @@
1
+ """Vectorized CartPole-v1 evaluator for PolyStep RL benchmarks.
2
+
3
+ Pure analytic dynamics - no Gymnasium dependency at evaluation time. The
4
+ dynamics, thresholds, and initial state distribution match Gymnasium's
5
+ ``CartPole-v1`` exactly so SB3 baselines trained on the Gym env transfer.
6
+
7
+ Reward: +1 per step while alive; episode terminates when ``|x| > 2.4`` or
8
+ ``|theta| > 12 deg``. Max horizon 500 ⇒ max return 500.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+ from typing import Dict
16
+
17
+ import torch
18
+
19
+ from .policies import count_stacked_candidates
20
+
21
+
22
+ OBS_DIM = 4
23
+ ACTION_DIM = 2
24
+ GRAVITY = 9.8
25
+ MASSCART = 1.0
26
+ MASSPOLE = 0.1
27
+ TOTAL_MASS = MASSCART + MASSPOLE
28
+ LENGTH = 0.5 # actually half the pole's length
29
+ POLEMASS_LENGTH = MASSPOLE * LENGTH
30
+ FORCE_MAG = 10.0
31
+ TAU = 0.02 # seconds between state updates
32
+ THETA_THRESHOLD = 12 * 2 * math.pi / 360
33
+ X_THRESHOLD = 2.4
34
+ INIT_RANGE = 0.05
35
+ DEFAULT_HORIZON = 500
36
+
37
+
38
+ def sample_initial_states(num: int, *, seed: int, device: str | torch.device = "cpu") -> torch.Tensor:
39
+ """Uniform [-0.05, 0.05]^4 init matching Gym's ``CartPole-v1.reset``."""
40
+
41
+ device = torch.device(device)
42
+ gen = torch.Generator(device="cpu")
43
+ gen.manual_seed(int(seed))
44
+ states = (torch.rand(num, OBS_DIM, generator=gen) * 2 - 1) * INIT_RANGE
45
+ return states.to(device)
46
+
47
+
48
+ def cartpole_step(states: torch.Tensor, actions: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
49
+ """Vectorized CartPole transition.
50
+
51
+ Args:
52
+ states: ``(N, 4)`` float tensor [x, x_dot, theta, theta_dot].
53
+ actions: ``(N,)`` long tensor in {0, 1}.
54
+
55
+ Returns:
56
+ ``(next_states, reward, done)`` where reward is +1 (alive) and ``done``
57
+ flags physically-terminated episodes.
58
+ """
59
+
60
+ x = states[..., 0]
61
+ x_dot = states[..., 1]
62
+ theta = states[..., 2]
63
+ theta_dot = states[..., 3]
64
+
65
+ force = torch.where(
66
+ actions.bool(),
67
+ torch.full_like(x, FORCE_MAG),
68
+ torch.full_like(x, -FORCE_MAG),
69
+ )
70
+ costheta = torch.cos(theta)
71
+ sintheta = torch.sin(theta)
72
+ temp = (force + POLEMASS_LENGTH * theta_dot * theta_dot * sintheta) / TOTAL_MASS
73
+ thetaacc = (GRAVITY * sintheta - costheta * temp) / (
74
+ LENGTH * (4.0 / 3.0 - MASSPOLE * costheta * costheta / TOTAL_MASS)
75
+ )
76
+ xacc = temp - POLEMASS_LENGTH * thetaacc * costheta / TOTAL_MASS
77
+
78
+ next_x = x + TAU * x_dot
79
+ next_x_dot = x_dot + TAU * xacc
80
+ next_theta = theta + TAU * theta_dot
81
+ next_theta_dot = theta_dot + TAU * thetaacc
82
+
83
+ next_states = torch.stack([next_x, next_x_dot, next_theta, next_theta_dot], dim=-1)
84
+ done = (next_x.abs() > X_THRESHOLD) | (next_theta.abs() > THETA_THRESHOLD)
85
+ reward = torch.ones_like(x)
86
+ return next_states, reward, done
87
+
88
+
89
+ @dataclass
90
+ class CartPoleRolloutResult:
91
+ returns: torch.Tensor
92
+ lengths: torch.Tensor
93
+ successes: torch.Tensor # episode survived full horizon
94
+
95
+
96
+ def _batched_mlp_logits(
97
+ obs: torch.Tensor,
98
+ stacked_params: Dict[str, torch.Tensor],
99
+ n_candidates: int,
100
+ rollouts: int,
101
+ ) -> torch.Tensor:
102
+ """Apply N policies to (N, R, 4) observations via bmm. Returns (N, R, 2)."""
103
+
104
+ x = obs # (N, R, 4)
105
+ linear_indices = sorted(int(k.split(".")[1]) for k in stacked_params if k.endswith(".weight"))
106
+ for j, idx in enumerate(linear_indices):
107
+ w = stacked_params[f"net.{idx}.weight"] # (N, out, in)
108
+ b = stacked_params[f"net.{idx}.bias"] # (N, out)
109
+ x = torch.bmm(x, w.transpose(1, 2)) + b.unsqueeze(1)
110
+ if j < len(linear_indices) - 1:
111
+ x = torch.tanh(x)
112
+ return x
113
+
114
+
115
+ class CartPoleEvaluator:
116
+ """Vectorized evaluator for stacked MLP policies on CartPole-v1."""
117
+
118
+ env_id = "CartPole-v1"
119
+ obs_dim = OBS_DIM
120
+ action_dim = ACTION_DIM
121
+ action_type = "discrete"
122
+
123
+ def __init__(
124
+ self,
125
+ rollouts_per_candidate: int = 32,
126
+ horizon: int = DEFAULT_HORIZON,
127
+ device: str = "cpu",
128
+ ):
129
+ self.rollouts_per_candidate = int(rollouts_per_candidate)
130
+ self.horizon = int(horizon)
131
+ self.device = torch.device(device)
132
+
133
+ def rollout_stacked_params(
134
+ self,
135
+ stacked_params: Dict[str, torch.Tensor],
136
+ *,
137
+ seed: int,
138
+ step: int,
139
+ ) -> CartPoleRolloutResult:
140
+ n_candidates = count_stacked_candidates(stacked_params)
141
+ R = self.rollouts_per_candidate
142
+ # CRN: same initial states for every candidate within a step; vary across steps.
143
+ states = (
144
+ sample_initial_states(
145
+ R,
146
+ seed=int(seed) + 1009 * int(step),
147
+ device=self.device,
148
+ )
149
+ .unsqueeze(0)
150
+ .expand(n_candidates, R, OBS_DIM)
151
+ .contiguous()
152
+ )
153
+
154
+ returns = torch.zeros(n_candidates, R, device=self.device)
155
+ lengths = torch.zeros(n_candidates, R, device=self.device)
156
+ active = torch.ones(n_candidates, R, dtype=torch.bool, device=self.device)
157
+ # Send stacked params to device.
158
+ sp = {k: v.to(self.device) for k, v in stacked_params.items()}
159
+
160
+ # Check ``active.any()`` only every ``early_stop_check`` steps to
161
+ # amortize the GPU-CPU sync cost. CartPole rewards stop accumulating
162
+ # once an env is inactive, so running a few extra "dead" iterations
163
+ # is cheap and keeps the inner loop fully on-device.
164
+ early_stop_check = max(1, self.horizon // 8)
165
+ for t in range(self.horizon):
166
+ logits = _batched_mlp_logits(states, sp, n_candidates, R) # (N, R, 2)
167
+ actions = logits.argmax(dim=-1) # (N, R)
168
+ flat_states = states.reshape(-1, OBS_DIM)
169
+ flat_actions = actions.reshape(-1)
170
+ next_flat, rewards_flat, done_flat = cartpole_step(flat_states, flat_actions)
171
+ next_states = next_flat.reshape(n_candidates, R, OBS_DIM)
172
+ rewards = rewards_flat.reshape(n_candidates, R)
173
+ done = done_flat.reshape(n_candidates, R)
174
+
175
+ returns = returns + torch.where(active, rewards, torch.zeros_like(rewards))
176
+ lengths = lengths + active.float()
177
+ states = torch.where(active.unsqueeze(-1), next_states, states)
178
+ active = active & ~done
179
+ if (t + 1) % early_stop_check == 0 and not bool(active.any()):
180
+ break
181
+
182
+ successes = active # survived all horizon steps
183
+ return CartPoleRolloutResult(returns=returns, lengths=lengths, successes=successes)
184
+
185
+ def loss_for_stacked_params(
186
+ self,
187
+ stacked_params: Dict[str, torch.Tensor],
188
+ seed: int,
189
+ step: int,
190
+ ) -> torch.Tensor:
191
+ result = self.rollout_stacked_params(stacked_params, seed=seed, step=step)
192
+ return -result.returns.mean(dim=1).to(dtype=torch.float32)
193
+
194
+ def summarize_stacked_params(
195
+ self,
196
+ stacked_params: Dict[str, torch.Tensor],
197
+ *,
198
+ seed: int,
199
+ step: int = 0,
200
+ ) -> Dict[str, float]:
201
+ result = self.rollout_stacked_params(stacked_params, seed=seed, step=step)
202
+ return {
203
+ "mean_return": float(result.returns.mean().item()),
204
+ "success_rate": float(result.successes.float().mean().item()),
205
+ "episode_length": float(result.lengths.mean().item()),
206
+ }
207
+
208
+
209
+ def evaluate_policy_module(
210
+ module: torch.nn.Module,
211
+ *,
212
+ seed: int,
213
+ episodes: int = 100,
214
+ horizon: int = DEFAULT_HORIZON,
215
+ device: str = "cpu",
216
+ ) -> Dict[str, float]:
217
+ """Evaluate a single ``DiscreteMLPPolicy`` module deterministically."""
218
+
219
+ from .policies import stack_module_params
220
+
221
+ evaluator = CartPoleEvaluator(
222
+ rollouts_per_candidate=int(episodes),
223
+ horizon=horizon,
224
+ device=device,
225
+ )
226
+ return evaluator.summarize_stacked_params(
227
+ stack_module_params(module, 1),
228
+ seed=seed,
229
+ )
230
+
231
+
232
+ def random_policy_baseline(
233
+ *,
234
+ seed: int,
235
+ episodes: int = 100,
236
+ horizon: int = DEFAULT_HORIZON,
237
+ device: str = "cpu",
238
+ ) -> Dict[str, float]:
239
+ """Uniform-random action baseline for CartPole."""
240
+
241
+ gen = torch.Generator(device="cpu")
242
+ gen.manual_seed(int(seed))
243
+ states = sample_initial_states(int(episodes), seed=seed, device=device)
244
+ returns = torch.zeros(int(episodes), device=device)
245
+ lengths = torch.zeros(int(episodes), device=device)
246
+ active = torch.ones(int(episodes), dtype=torch.bool, device=device)
247
+ for _ in range(int(horizon)):
248
+ actions = torch.randint(0, ACTION_DIM, (int(episodes),), generator=gen).to(device)
249
+ next_states, rewards, done = cartpole_step(states, actions)
250
+ returns = returns + torch.where(active, rewards, torch.zeros_like(rewards))
251
+ lengths = lengths + active.float()
252
+ states = torch.where(active.unsqueeze(-1), next_states, states)
253
+ active = active & ~done
254
+ if not bool(active.any()):
255
+ break
256
+ return {
257
+ "mean_return": float(returns.mean().item()),
258
+ "success_rate": float(active.float().mean().item()),
259
+ "episode_length": float(lengths.mean().item()),
260
+ }
@@ -0,0 +1,304 @@
1
+ """Generic vectorized Gymnasium evaluator for stacked MLP policies.
2
+
3
+ Wraps any discrete-action Gymnasium environment via ``SyncVectorEnv`` and
4
+ evaluates ``N`` stacked candidate policies in parallel by running ``N * R``
5
+ parallel envs and dispatching each env to its assigned candidate. The API
6
+ mirrors :class:`polystep.benchmarks.rl.cartpole.CartPoleEvaluator` so it can be
7
+ dropped into the same training loop in ``experiments/runners/run_rl.py``.
8
+
9
+ Notes
10
+ -----
11
+ - Works with any Gymnasium ``Discrete``-action environment whose observation
12
+ space is a ``Box`` (or anything yielding a fixed-size ``ndarray``).
13
+ - For analytic, fully GPU-vectorizable envs (e.g. CartPole), prefer the
14
+ task-specific evaluator; this class is the right choice when you need
15
+ Gymnasium's exact dynamics or there is no analytic form.
16
+ - Uses CRN (common random numbers): every candidate sees the same per-(step,
17
+ rollout) seed in a given outer iteration, so evaluations within a step
18
+ differ only by the policy.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Callable, Dict, Optional
25
+
26
+ import numpy as np
27
+ import torch
28
+
29
+ from .policies import count_stacked_candidates
30
+
31
+
32
+ @dataclass
33
+ class GymRolloutResult:
34
+ returns: torch.Tensor # (N, R)
35
+ lengths: torch.Tensor # (N, R)
36
+ successes: torch.Tensor # (N, R) bool - "success" flag, env-specific
37
+
38
+
39
+ def _make_vector_env(env_id: str, total_envs: int):
40
+ """Create a SyncVectorEnv with ``total_envs`` instances of ``env_id``."""
41
+
42
+ import gymnasium as gym
43
+ from gymnasium.vector import SyncVectorEnv
44
+
45
+ def thunk():
46
+ return gym.make(env_id)
47
+
48
+ return SyncVectorEnv([thunk for _ in range(int(total_envs))])
49
+
50
+
51
+ def _batched_mlp_logits(
52
+ obs: torch.Tensor,
53
+ stacked_params: Dict[str, torch.Tensor],
54
+ activation: str = "tanh",
55
+ ) -> torch.Tensor:
56
+ """Apply N stacked policies to obs of shape (N, R, obs_dim).
57
+
58
+ ``activation`` selects the inner activation between linear layers:
59
+
60
+ - ``"tanh"`` - standard :class:`DiscreteMLPPolicy`.
61
+ - ``"int8"`` - per-tensor symmetric INT8 quantize/dequantize (no STE).
62
+ - ``"binary"`` - ``sign(x)`` activation (no STE).
63
+ - ``"float32"`` - identity.
64
+ """
65
+
66
+ x = obs # (N, R, obs_dim)
67
+ linear_indices = sorted(int(k.split(".")[1]) for k in stacked_params if k.endswith(".weight"))
68
+ for j, idx in enumerate(linear_indices):
69
+ w = stacked_params[f"net.{idx}.weight"] # (N, out, in)
70
+ b = stacked_params[f"net.{idx}.bias"] # (N, out)
71
+ x = torch.bmm(x, w.transpose(1, 2)) + b.unsqueeze(1)
72
+ if j < len(linear_indices) - 1:
73
+ if activation == "tanh":
74
+ x = torch.tanh(x)
75
+ elif activation == "int8":
76
+ # Per-candidate per-tensor quantization to mirror NonDiffActivation.
77
+ max_abs = x.detach().abs().amax(dim=(1, 2), keepdim=True).clamp(min=1e-8)
78
+ scale = max_abs / 127.0
79
+ x = torch.round(x / scale) * scale
80
+ elif activation == "binary":
81
+ x = torch.sign(x)
82
+ elif activation == "float32":
83
+ pass
84
+ else:
85
+ raise ValueError(f"Unknown activation {activation!r}")
86
+ return x
87
+
88
+
89
+ class GymVectorEvaluator:
90
+ """Vectorized evaluator for stacked discrete-action MLP policies on any Gymnasium env.
91
+
92
+ Parameters
93
+ ----------
94
+ env_id:
95
+ Gymnasium env identifier (e.g. ``"Acrobot-v1"``, ``"LunarLander-v3"``).
96
+ rollouts_per_candidate:
97
+ Number of parallel episodes per candidate (``R``).
98
+ horizon:
99
+ Maximum steps per episode. Defaults to the env's ``spec.max_episode_steps``.
100
+ device:
101
+ Device for policy forward passes. Env stays on CPU.
102
+ success_fn:
103
+ Optional callable ``(returns: Tensor (N,R), lengths: Tensor (N,R)) ->
104
+ Tensor[bool] (N,R)`` mapping per-episode return and length to a
105
+ "success" flag for logging. Defaults to "survived to ``horizon``"
106
+ (i.e., ``lengths == horizon``), which is correct for fixed-horizon
107
+ balancing tasks like CartPole. For environments where "success"
108
+ means reaching a goal in less than ``horizon`` steps (e.g.,
109
+ Acrobot, MountainCar), pass a custom ``success_fn``.
110
+ activation:
111
+ Inner activation between linear layers. ``"tanh"`` (default) for the
112
+ standard :class:`DiscreteMLPPolicy`; ``"int8"`` / ``"binary"`` /
113
+ ``"float32"`` for the non-differentiable variants used to motivate
114
+ gradient-free training (see :class:`NonDiffMLPPolicy`).
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ env_id: str,
120
+ *,
121
+ rollouts_per_candidate: int = 16,
122
+ horizon: Optional[int] = None,
123
+ device: str = "cpu",
124
+ success_fn: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
125
+ activation: str = "tanh",
126
+ ):
127
+ import gymnasium as gym
128
+
129
+ self.env_id = str(env_id)
130
+ self.rollouts_per_candidate = int(rollouts_per_candidate)
131
+ self.device = torch.device(device)
132
+ # Probe env for spec metadata.
133
+ probe = gym.make(self.env_id)
134
+ self.obs_dim = int(np.prod(probe.observation_space.shape))
135
+ if probe.action_space.__class__.__name__ != "Discrete":
136
+ probe.close()
137
+ raise ValueError(
138
+ f"GymVectorEvaluator requires Discrete action space; got {probe.action_space} for {env_id}"
139
+ )
140
+ self.action_dim = int(probe.action_space.n)
141
+ spec_horizon = getattr(probe.spec, "max_episode_steps", None) or 500
142
+ probe.close()
143
+ self.horizon = int(horizon) if horizon is not None else int(spec_horizon)
144
+ self.action_type = "discrete"
145
+ self.success_fn = success_fn
146
+ self.activation = str(activation)
147
+ # Cache vector env across calls; lazily (re)created when n_candidates changes.
148
+ self._cached_n_candidates: Optional[int] = None
149
+ self._venv = None
150
+
151
+ def _ensure_venv(self, n_candidates: int) -> None:
152
+ n_total = int(n_candidates) * self.rollouts_per_candidate
153
+ if self._venv is None or self._cached_n_candidates != n_candidates:
154
+ if self._venv is not None:
155
+ try:
156
+ self._venv.close()
157
+ except Exception: # noqa: BLE001
158
+ pass
159
+ self._venv = _make_vector_env(self.env_id, n_total)
160
+ self._cached_n_candidates = int(n_candidates)
161
+
162
+ def close(self) -> None:
163
+ if self._venv is not None:
164
+ try:
165
+ self._venv.close()
166
+ except Exception: # noqa: BLE001
167
+ pass
168
+ self._venv = None
169
+
170
+ def __del__(self): # best-effort cleanup
171
+ try:
172
+ self.close()
173
+ except Exception: # noqa: BLE001
174
+ pass
175
+
176
+ # ------------------------------------------------------------------
177
+ # Core rollout
178
+ # ------------------------------------------------------------------
179
+ def rollout_stacked_params(
180
+ self,
181
+ stacked_params: Dict[str, torch.Tensor],
182
+ *,
183
+ seed: int,
184
+ step: int,
185
+ ) -> GymRolloutResult:
186
+ n_candidates = count_stacked_candidates(stacked_params)
187
+ R = self.rollouts_per_candidate
188
+ N = n_candidates
189
+ self._ensure_venv(N)
190
+
191
+ # Per-env seed: deterministic per (step, env_index) so candidates share CRN
192
+ # within a step and seeds rotate across steps.
193
+ base_seed = int(seed) + 1009 * int(step)
194
+ seeds = [base_seed + i for i in range(N * R)]
195
+ obs_np, _ = self._venv.reset(seed=seeds)
196
+
197
+ # State tensors (on CPU then move to device per step for policy inference).
198
+ returns = torch.zeros(N, R, dtype=torch.float32)
199
+ lengths = torch.zeros(N, R, dtype=torch.float32)
200
+ active = np.ones(N * R, dtype=bool)
201
+
202
+ sp = {k: v.to(self.device) for k, v in stacked_params.items()}
203
+
204
+ for _t in range(self.horizon):
205
+ obs_t = torch.from_numpy(np.asarray(obs_np, dtype=np.float32)).to(self.device)
206
+ obs_t = obs_t.view(N, R, self.obs_dim)
207
+ with torch.no_grad():
208
+ logits = _batched_mlp_logits(obs_t, sp, activation=self.activation) # (N, R, action_dim)
209
+ actions = logits.argmax(dim=-1) # (N, R)
210
+ actions_np = actions.cpu().numpy().reshape(N * R).astype(np.int64)
211
+ obs_np, rewards_np, terminated_np, truncated_np, _ = self._venv.step(actions_np)
212
+ done_np = np.logical_or(terminated_np, truncated_np)
213
+
214
+ r_active = active.astype(np.float32)
215
+ rew_active = rewards_np.astype(np.float32) * r_active
216
+ returns += torch.from_numpy(rew_active.reshape(N, R))
217
+ lengths += torch.from_numpy(r_active.reshape(N, R))
218
+
219
+ # Mark envs that just terminated as inactive going forward; further
220
+ # rewards from auto-reset are masked out by the `active` flag.
221
+ active = active & ~done_np
222
+ if not active.any():
223
+ break
224
+
225
+ # Successes: env-specific. ``success_fn`` lets the caller supply
226
+ # env-aware logic; the default "survived to horizon" is appropriate
227
+ # for fixed-horizon balancing tasks (CartPole) and clearly wrong for
228
+ # goal-reaching tasks (Acrobot), which is why custom ``success_fn``
229
+ # is encouraged.
230
+ if self.success_fn is not None:
231
+ successes = self.success_fn(returns, lengths)
232
+ else:
233
+ successes = lengths >= float(self.horizon)
234
+
235
+ return GymRolloutResult(
236
+ returns=returns.to(self.device),
237
+ lengths=lengths.to(self.device),
238
+ successes=successes.to(self.device),
239
+ )
240
+
241
+ # ------------------------------------------------------------------
242
+ # Loss + summary helpers (matches CartPoleEvaluator interface)
243
+ # ------------------------------------------------------------------
244
+ def loss_for_stacked_params(
245
+ self,
246
+ stacked_params: Dict[str, torch.Tensor],
247
+ seed: int,
248
+ step: int,
249
+ ) -> torch.Tensor:
250
+ result = self.rollout_stacked_params(stacked_params, seed=seed, step=step)
251
+ return -result.returns.mean(dim=1).to(dtype=torch.float32)
252
+
253
+ def summarize_stacked_params(
254
+ self,
255
+ stacked_params: Dict[str, torch.Tensor],
256
+ *,
257
+ seed: int,
258
+ step: int = 0,
259
+ ) -> Dict[str, float]:
260
+ result = self.rollout_stacked_params(stacked_params, seed=seed, step=step)
261
+ return {
262
+ "mean_return": float(result.returns.mean().item()),
263
+ "success_rate": float(result.successes.float().mean().item()),
264
+ "episode_length": float(result.lengths.mean().item()),
265
+ }
266
+
267
+
268
+ def random_policy_baseline(
269
+ env_id: str,
270
+ *,
271
+ seed: int,
272
+ episodes: int = 100,
273
+ horizon: Optional[int] = None,
274
+ ) -> Dict[str, float]:
275
+ """Uniform-random action baseline for any Gymnasium discrete-action env."""
276
+
277
+ import gymnasium as gym
278
+
279
+ rng = np.random.default_rng(int(seed))
280
+ venv = _make_vector_env(env_id, int(episodes))
281
+ obs_np, _ = venv.reset(seed=[int(seed) + i for i in range(int(episodes))])
282
+ if horizon is None:
283
+ probe = gym.make(env_id)
284
+ horizon = getattr(probe.spec, "max_episode_steps", None) or 500
285
+ probe.close()
286
+ n_actions = venv.single_action_space.n
287
+ returns = np.zeros(int(episodes), dtype=np.float64)
288
+ lengths = np.zeros(int(episodes), dtype=np.float64)
289
+ active = np.ones(int(episodes), dtype=bool)
290
+ for _ in range(int(horizon)):
291
+ actions = rng.integers(0, n_actions, size=int(episodes))
292
+ obs_np, rewards_np, terminated_np, truncated_np, _ = venv.step(actions)
293
+ done_np = np.logical_or(terminated_np, truncated_np)
294
+ returns += rewards_np * active
295
+ lengths += active.astype(np.float64)
296
+ active = active & ~done_np
297
+ if not active.any():
298
+ break
299
+ venv.close()
300
+ return {
301
+ "mean_return": float(returns.mean()),
302
+ "success_rate": float((~active).mean()),
303
+ "episode_length": float(lengths.mean()),
304
+ }
@@ -0,0 +1,53 @@
1
+ """Metric helpers for RL experiment results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+
8
+ def normalize_score(value: float, random_return: float, reference_return: float) -> float:
9
+ """Normalize a return so random is 0 and a reference policy is 1."""
10
+
11
+ denom = reference_return - random_return
12
+ if abs(denom) < 1e-12:
13
+ return 0.0
14
+ return float((value - random_return) / denom)
15
+
16
+
17
+ def build_rl_metrics(
18
+ *,
19
+ final_return: float,
20
+ best_return: float,
21
+ normalized_score: float,
22
+ wall_time_seconds: float,
23
+ peak_gpu_memory_mb: float,
24
+ function_evals: int,
25
+ total_steps: int,
26
+ rl_env_steps: int,
27
+ best_normalized_score: float | None = None,
28
+ **extra: Any,
29
+ ) -> Dict[str, Any]:
30
+ """Build metrics compatible with ``experiments.runners.common.save_result``.
31
+
32
+ Returns explicit ``*_return`` fields plus ``*_accuracy`` aliases for
33
+ schema compatibility with non-RL benchmarks. When
34
+ ``best_normalized_score`` is omitted, ``best_accuracy`` falls back to
35
+ ``normalized_score`` (preserves earlier behavior); pass it explicitly to
36
+ distinguish best from final.
37
+ """
38
+
39
+ best_norm = float(best_normalized_score) if best_normalized_score is not None else float(normalized_score)
40
+ metrics: Dict[str, Any] = {
41
+ "final_accuracy": float(normalized_score),
42
+ "best_accuracy": best_norm,
43
+ "wall_time_seconds": float(wall_time_seconds),
44
+ "peak_gpu_memory_mb": float(peak_gpu_memory_mb),
45
+ "function_evals": int(function_evals),
46
+ "total_steps": int(total_steps),
47
+ "final_return": float(final_return),
48
+ "best_return": float(best_return),
49
+ "normalized_score": float(normalized_score),
50
+ "rl_env_steps": int(rl_env_steps),
51
+ }
52
+ metrics.update(extra)
53
+ return metrics