env-ssl-wrapper 0.4.6__tar.gz → 0.5.0__tar.gz

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 (26) hide show
  1. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/PKG-INFO +4 -2
  2. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/README.md +1 -1
  3. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/__init__.py +3 -0
  4. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/action_chunk.py +12 -1
  5. env_ssl_wrapper-0.5.0/env_ssl_wrapper/evaluate.py +337 -0
  6. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/pyproject.toml +3 -1
  7. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/.gitignore +0 -0
  8. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/LICENSE +0 -0
  9. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/memory_trace.py +0 -0
  10. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/__init__.py +0 -0
  11. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/action_transform_wrapper.py +0 -0
  12. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/adapters.py +0 -0
  13. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/auto_batched_wrapper.py +0 -0
  14. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/done_tracker_wrapper.py +0 -0
  15. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/episode_padding_wrapper.py +0 -0
  16. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/flatten_obs_wrapper.py +0 -0
  17. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/helpers.py +0 -0
  18. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/image_wrapper.py +0 -0
  19. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/mocks.py +0 -0
  20. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/spaces.py +0 -0
  21. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/standardize_env_wrapper.py +0 -0
  22. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/standardize_wrapper.py +0 -0
  23. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/tensor_wrapper.py +0 -0
  24. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/time_limit_wrapper.py +0 -0
  25. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/utils.py +0 -0
  26. {env_ssl_wrapper-0.4.6 → env_ssl_wrapper-0.5.0}/env_ssl_wrapper/standardize/vector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-ssl-wrapper
3
- Version: 0.4.6
3
+ Version: 0.5.0
4
4
  Summary: One torch-native interface for any MDP environment
5
5
  Project-URL: Homepage, https://pypi.org/project/env-ssl-wrapper/
6
6
  Project-URL: Repository, https://codeberg.org/lucidrains/env-ssl-wrapper
@@ -37,6 +37,8 @@ Requires-Python: >=3.10
37
37
  Requires-Dist: discrete-continuous-embed-readout
38
38
  Requires-Dist: einops>=0.8.1
39
39
  Requires-Dist: einx>=0.3.0
40
+ Requires-Dist: imageio
41
+ Requires-Dist: imageio-ffmpeg
40
42
  Requires-Dist: memmap-replay-buffer
41
43
  Requires-Dist: torch-einops-utils>=0.0.29
42
44
  Requires-Dist: torch>=2.5
@@ -161,7 +163,7 @@ while True:
161
163
  obs, info = env.reset()
162
164
  ```
163
165
 
164
- Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions).
166
+ Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions). The wrapper can also be applied directly to raw gymnasium environments, single or vectorized — torch action chunks are converted to numpy for the underlying simulator.
165
167
 
166
168
  Execution stops early the moment any env terminates or truncates mid-chunk — the terminal state and done flags of that substep are returned and the rest of the chunk is dropped, so a new episode is never silently advanced.
167
169
 
@@ -98,7 +98,7 @@ while True:
98
98
  obs, info = env.reset()
99
99
  ```
100
100
 
101
- Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions).
101
+ Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions). The wrapper can also be applied directly to raw gymnasium environments, single or vectorized — torch action chunks are converted to numpy for the underlying simulator.
102
102
 
103
103
  Execution stops early the moment any env terminates or truncates mid-chunk — the terminal state and done flags of that substep are returned and the rest of the chunk is dropped, so a new episode is never silently advanced.
104
104
 
@@ -6,6 +6,7 @@ from .standardize import *
6
6
 
7
7
  from .memory_trace import MemoryTraceWrapper
8
8
  from .action_chunk import ActionChunkWrapper
9
+ from .evaluate import evaluate_actor, EpisodeStats
9
10
 
10
11
  # Wire backwards-compatibility aliases in sys.modules and module globals
11
12
  # so imports like `from env_ssl_wrapper.done_tracker_wrapper import DoneTrackerWrapper`
@@ -40,6 +41,8 @@ __all__ = [
40
41
  *standardize.__all__,
41
42
  'MemoryTraceWrapper',
42
43
  'ActionChunkWrapper',
44
+ 'evaluate_actor',
45
+ 'EpisodeStats',
43
46
  ]
44
47
 
45
48
  def __getattr__(name):
@@ -11,8 +11,10 @@ from .standardize.helpers import (
11
11
  default,
12
12
  any_true,
13
13
  dones_of,
14
+ first_existing,
14
15
  get_attr,
15
16
  is_vectorized,
17
+ to_numpy,
16
18
  )
17
19
 
18
20
  # helpers
@@ -62,9 +64,13 @@ class ActionChunkWrapper(EnvWrapper):
62
64
  self.gamma = float(gamma)
63
65
  self.reward_mode = reward_mode
64
66
 
65
- action_space = get_attr(env, 'action_space')
67
+ # vectorized gym envs expose a batched action space - the single env space defines the chunk layout
68
+
69
+ action_space = first_existing(env, 'single_action_space', 'action_space')
66
70
  self.action_shape = tuple(get_attr(action_space, 'shape', ()) or ())
71
+
67
72
  self.expects_batch = is_vectorized(env)
73
+ self.raw_env = not isinstance(env, EnvWrapper)
68
74
 
69
75
  @property
70
76
  def chunk_action_shape(self):
@@ -80,6 +86,11 @@ class ActionChunkWrapper(EnvWrapper):
80
86
  if not (is_tensor(actions) or isinstance(actions, np.ndarray)):
81
87
  actions = np.asarray(actions)
82
88
 
89
+ # raw simulators only understand numpy - wrapper chains from this package handle torch actions
90
+
91
+ if self.raw_env and is_tensor(actions):
92
+ actions = to_numpy(actions)
93
+
83
94
  chunk_axis = actions.ndim - 1 - len(self.action_shape)
84
95
 
85
96
  assert chunk_axis >= 1, (
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import NamedTuple
5
+
6
+ import numpy as np
7
+ import torch
8
+ from torch import nn, is_tensor
9
+ from torch.distributions import Categorical, Distribution
10
+ from torch.utils._pytree import tree_flatten, tree_map
11
+
12
+ from .standardize.helpers import (
13
+ EnvWrapper,
14
+ env_autoresets,
15
+ env_num_envs,
16
+ env_render,
17
+ exists,
18
+ first_existing,
19
+ get_adapter,
20
+ get_attr,
21
+ is_array,
22
+ is_vectorized,
23
+ normalize_reset_out,
24
+ normalize_step_out,
25
+ to_numpy,
26
+ )
27
+
28
+ # results
29
+
30
+ class EpisodeStats(NamedTuple):
31
+ returns: torch.Tensor
32
+ lengths: torch.Tensor
33
+ videos: tuple[str, ...] = ()
34
+
35
+ @property
36
+ def num_episodes(self):
37
+ return self.returns.numel()
38
+
39
+ @property
40
+ def mean(self):
41
+ return self.returns.mean().item()
42
+
43
+ @property
44
+ def std(self):
45
+ return self.returns.std(unbiased = False).item()
46
+
47
+ @property
48
+ def min(self):
49
+ return self.returns.min().item()
50
+
51
+ @property
52
+ def max(self):
53
+ return self.returns.max().item()
54
+
55
+ @property
56
+ def video_path(self):
57
+ return self.videos[0] if self.videos else None
58
+
59
+ def success_rate(self, threshold):
60
+ return (self.returns >= threshold).float().mean().item()
61
+
62
+ def summary(self):
63
+ return dict(episodes = self.num_episodes, mean = self.mean, std = self.std, min = self.min, max = self.max)
64
+
65
+ # actor output -> action
66
+
67
+ def resolve_actor(actor):
68
+ # a callable `.dist` takes precedence, sb3 style
69
+
70
+ module = actor if isinstance(actor, nn.Module) else None
71
+ policy = get_attr(actor, 'dist')
72
+
73
+ if not callable(policy):
74
+ policy = actor
75
+
76
+ assert callable(policy), 'actor must be callable, or expose a callable `.dist`'
77
+
78
+ return policy, module
79
+
80
+ def is_distribution(out):
81
+ if isinstance(out, Distribution):
82
+ return True
83
+
84
+ if is_tensor(out):
85
+ return False
86
+
87
+ return callable(get_attr(out, 'sample')) and (exists(get_attr(out, 'mean')) or exists(get_attr(out, 'probs')))
88
+
89
+ def to_action(env, out, deterministic = True):
90
+ # distributions collapse to their mode, raw floating logits over a discrete space to their argmax
91
+
92
+ if is_distribution(out):
93
+ if not deterministic:
94
+ return out.sample()
95
+
96
+ return out.probs.argmax(dim = -1) if exists(get_attr(out, 'probs')) else out.mean
97
+
98
+ if is_tensor(out) and out.is_floating_point() and out.ndim > 0:
99
+ num_actions = get_attr(get_adapter(env).action_space, 'n')
100
+
101
+ if exists(num_actions) and out.shape[-1] == num_actions:
102
+ return out.argmax(dim = -1) if deterministic else Categorical(logits = out).sample()
103
+
104
+ return out
105
+
106
+ # states and actions
107
+
108
+ def to_actor_obs(obs, device, batched):
109
+ def convert(x):
110
+ if not (is_array(x) or callable(get_attr(x, '__array__'))):
111
+ return x
112
+
113
+ tensor = x if is_tensor(x) else torch.as_tensor(np.asarray(x))
114
+
115
+ if tensor.is_floating_point():
116
+ tensor = tensor.float()
117
+
118
+ tensor = tensor.to(device)
119
+
120
+ return tensor if batched or tensor.ndim == 0 else tensor[None]
121
+
122
+ return tree_map(convert, obs)
123
+
124
+ def to_env_action(env, action):
125
+ if not is_tensor(action):
126
+ action = torch.as_tensor(action)
127
+
128
+ device = get_attr(env, 'device')
129
+ action = action.to(device) if exists(device) else action
130
+
131
+ # raw simulators take unbatched numpy
132
+
133
+ if not isinstance(env, EnvWrapper):
134
+ action = to_numpy(action)
135
+
136
+ if action.ndim > 1 and action.shape[0] == 1:
137
+ action = action[0]
138
+
139
+ return action.item() if action.ndim == 0 else action
140
+
141
+ # wrappers take batched torch actions - pad up to the declared rank
142
+
143
+ chunk_shape = get_attr(env, 'chunk_action_shape')
144
+
145
+ if exists(chunk_shape):
146
+ rank = len(chunk_shape) + 1
147
+ else:
148
+ space = first_existing(env, 'single_action_space', 'action_space')
149
+ rank = len(get_attr(space, 'shape', ())) + 1
150
+
151
+ while action.ndim < rank:
152
+ action = action[None]
153
+
154
+ return action
155
+
156
+
157
+ # video
158
+
159
+ def capture_frame(env):
160
+ render = get_attr(env, 'render')
161
+ frame = render() if callable(render) else None
162
+
163
+ if not exists(frame):
164
+ try:
165
+ frame = env_render(env, 256, 256)
166
+ except Exception:
167
+ return None
168
+
169
+ if is_tensor(frame):
170
+ frame = frame.detach().cpu().numpy()
171
+
172
+ frame = np.asarray(frame)
173
+
174
+ if frame.ndim == 4:
175
+ frame = frame[0]
176
+
177
+ if frame.ndim == 3 and frame.shape[0] in (3, 4) and frame.shape[-1] not in (3, 4):
178
+ frame = np.moveaxis(frame, 0, -1)
179
+
180
+ if frame.ndim == 3 and frame.shape[-1] == 4:
181
+ frame = frame[..., :3]
182
+
183
+ if frame.dtype != np.uint8:
184
+ scale = 255. if frame.max() <= 1. else 1.
185
+ frame = np.clip(frame * scale, 0., 255.).astype(np.uint8)
186
+
187
+ return frame
188
+
189
+ def save_video(frames, path, fps = 30):
190
+ import imageio
191
+
192
+ path = Path(path)
193
+ path.parent.mkdir(parents = True, exist_ok = True)
194
+ imageio.mimsave(path, frames, fps = fps, macro_block_size = None)
195
+
196
+ def video_path_at(path, index):
197
+ path = Path(path)
198
+ return str(path if index == 0 else path.with_name(f'{path.stem}_ep{index}{path.suffix}'))
199
+
200
+ # evaluate
201
+
202
+ @torch.no_grad()
203
+ def evaluate_actor(
204
+ actor,
205
+ env,
206
+ episodes = 10,
207
+ max_steps = 1000,
208
+ seed = None,
209
+ goal = None,
210
+ device = None,
211
+ deterministic = True,
212
+ video_path = None,
213
+ video_episodes = 1,
214
+ fps = 30,
215
+ verbose = False
216
+ ):
217
+ # seed fixes the episode seeds, so checkpoints are compared on identical episodes
218
+ # video_path records the first video_episodes episodes
219
+
220
+ policy, module = resolve_actor(actor)
221
+
222
+ if not exists(device):
223
+ params = next(module.parameters(), None) if exists(module) else None
224
+ device = get_attr(params, 'device', torch.device('cpu'))
225
+
226
+ was_training = get_attr(module, 'training')
227
+
228
+ if exists(module):
229
+ module.eval()
230
+
231
+ if exists(goal):
232
+ goal = torch.as_tensor(goal) if not is_tensor(goal) else goal
233
+ goal = goal.to(device)
234
+ goal = goal[None] if goal.ndim == 1 else goal
235
+
236
+ num_envs = env_num_envs(env)
237
+ record = exists(video_path)
238
+
239
+ assert not (record and num_envs > 1), 'video recording requires a single env'
240
+
241
+ obs, info = normalize_reset_out(env.reset(seed = seed) if exists(seed) else env.reset())
242
+ batched = is_vectorized(env) or all(map(is_tensor, tree_flatten(obs)[0]))
243
+
244
+ goal = goal.expand(num_envs, -1) if exists(goal) and goal.shape[0] == 1 and num_envs > 1 else goal
245
+
246
+ returns = torch.zeros(num_envs)
247
+ lengths = torch.zeros(num_envs)
248
+ steps = torch.zeros(num_envs, dtype = torch.long)
249
+
250
+ done_returns, done_lengths, videos, frames = [], [], [], []
251
+
252
+ try:
253
+ while len(done_returns) < episodes:
254
+ if record and len(done_returns) < video_episodes:
255
+ frame = capture_frame(env)
256
+ if exists(frame):
257
+ frames.append(frame)
258
+
259
+ state = to_actor_obs(obs, device, batched)
260
+ out = policy(state, goal) if exists(goal) else policy(state)
261
+ action = to_env_action(env, to_action(env, out, deterministic))
262
+
263
+ obs, reward, terminated, truncated, info = normalize_step_out(env.step(action))
264
+
265
+ # reward chunks collapse to one scalar per env
266
+
267
+ reward = torch.as_tensor(to_numpy(reward), dtype = torch.float32)
268
+
269
+ while reward.ndim > 1:
270
+ reward = reward.sum(dim = -1)
271
+
272
+ if reward.numel() > num_envs:
273
+ reward = reward.reshape(num_envs, -1).sum(dim = -1)
274
+
275
+ returns += reward if reward.numel() == num_envs else reward.expand(num_envs)
276
+
277
+ lengths += info.get('chunk_length', 1) if isinstance(info, dict) else 1
278
+ steps += 1
279
+
280
+ done = to_numpy(terminated | truncated).astype(bool).reshape(-1)
281
+
282
+ if exists(max_steps):
283
+ done |= to_numpy(steps >= max_steps).reshape(-1)
284
+
285
+ for i in np.where(done)[0]:
286
+ if len(done_returns) >= episodes:
287
+ break
288
+
289
+ episode = len(done_returns)
290
+ done_returns.append(returns[i].item())
291
+ done_lengths.append(lengths[i].item())
292
+
293
+ if verbose:
294
+ print(f'episode {episode:3d} | return {done_returns[-1]:8.1f} | length {done_lengths[-1]:7.0f}', flush = True)
295
+
296
+ if record and episode < video_episodes:
297
+ frame = capture_frame(env)
298
+ if exists(frame):
299
+ frames.append(frame)
300
+
301
+ if frames:
302
+ path = video_path_at(video_path, episode)
303
+ save_video(frames, path, fps)
304
+ videos.append(path)
305
+
306
+ returns[i] = 0.
307
+ lengths[i] = 0.
308
+ steps[i] = 0
309
+
310
+ if done.any() and len(done_returns) < episodes:
311
+ if record:
312
+ frames = []
313
+
314
+ # autoresetting envs reset themselves, everything else resets here
315
+
316
+ if not env_autoresets(env) and (num_envs == 1 or done.all() or bool(get_attr(env, 'needs_reset', False))):
317
+ reset_seed = seed + len(done_returns) if exists(seed) and num_envs == 1 else None
318
+ obs, info = normalize_reset_out(env.reset(seed = reset_seed) if exists(reset_seed) else env.reset())
319
+ finally:
320
+ if exists(module) and exists(was_training):
321
+ module.train(was_training)
322
+
323
+ return EpisodeStats(
324
+ returns = torch.tensor(done_returns, dtype = torch.float32),
325
+ lengths = torch.tensor(done_lengths, dtype = torch.float32),
326
+ videos = tuple(videos)
327
+ )
328
+
329
+ if __name__ == '__main__':
330
+ import gymnasium as gym
331
+ from env_ssl_wrapper import StandardizeEnvWrapper
332
+
333
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
334
+
335
+ stats = evaluate_actor(lambda obs: torch.zeros(obs.shape[0], 2), env, episodes = 10, seed = 0, verbose = True)
336
+
337
+ print(stats.summary())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-ssl-wrapper"
3
- version = "0.4.6"
3
+ version = "0.5.0"
4
4
  description = "One torch-native interface for any MDP environment"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
@@ -27,6 +27,8 @@ dependencies = [
27
27
  "discrete-continuous-embed-readout",
28
28
  "einx>=0.3.0",
29
29
  "einops>=0.8.1",
30
+ "imageio",
31
+ "imageio-ffmpeg",
30
32
  "memmap-replay-buffer",
31
33
  "torch>=2.5",
32
34
  "torch-einops-utils>=0.0.29",
File without changes