polystep 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polystep/__init__.py +176 -0
- polystep/_compiled.py +334 -0
- polystep/_step_blockwise.py +994 -0
- polystep/_step_momentum.py +128 -0
- polystep/_step_monolithic.py +1070 -0
- polystep/adaptive_subspace.py +762 -0
- polystep/api.py +307 -0
- polystep/ask_tell.py +164 -0
- polystep/benchmarks/__init__.py +62 -0
- polystep/benchmarks/baselines.py +604 -0
- polystep/benchmarks/rl/__init__.py +13 -0
- polystep/benchmarks/rl/cartpole.py +260 -0
- polystep/benchmarks/rl/gym_evaluator.py +304 -0
- polystep/benchmarks/rl/metrics.py +53 -0
- polystep/benchmarks/rl/policies.py +133 -0
- polystep/benchmarks/utils.py +1262 -0
- polystep/blockwise.py +549 -0
- polystep/cma.py +400 -0
- polystep/cma_subspace.py +404 -0
- polystep/cost_nn.py +633 -0
- polystep/costs.py +86 -0
- polystep/dynamics.py +138 -0
- polystep/epsilon.py +217 -0
- polystep/geometry.py +410 -0
- polystep/hybrid_subspace.py +1335 -0
- polystep/layers/__init__.py +16 -0
- polystep/layers/attention.py +243 -0
- polystep/layers/rnn.py +276 -0
- polystep/objectives/__init__.py +25 -0
- polystep/objectives/base.py +77 -0
- polystep/objectives/synthetic.py +232 -0
- polystep/optimizer.py +1405 -0
- polystep/projection/__init__.py +5 -0
- polystep/projection/sparse.py +291 -0
- polystep/py.typed +0 -0
- polystep/quadratic_model.py +285 -0
- polystep/solver.py +654 -0
- polystep/solvers/__init__.py +34 -0
- polystep/solvers/_prelude.py +129 -0
- polystep/solvers/base.py +77 -0
- polystep/solvers/greedy.py +159 -0
- polystep/solvers/kl_softmax.py +200 -0
- polystep/solvers/sinkhorn.py +522 -0
- polystep/solvers/softmax.py +151 -0
- polystep/solvers/tempered_softmax.py +97 -0
- polystep/subspace.py +694 -0
- polystep/transform.py +365 -0
- polystep-0.7.0.dist-info/METADATA +285 -0
- polystep-0.7.0.dist-info/RECORD +52 -0
- polystep-0.7.0.dist-info/WHEEL +5 -0
- polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
- polystep-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|