env-ssl-wrapper 0.4.1__tar.gz → 0.4.3__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 (25) hide show
  1. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/PKG-INFO +53 -2
  2. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/README.md +49 -1
  3. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/__init__.py +2 -0
  4. env_ssl_wrapper-0.4.3/env_ssl_wrapper/action_chunk.py +117 -0
  5. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/action_transform_wrapper.py +8 -3
  6. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/adapters.py +13 -41
  7. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/done_tracker_wrapper.py +2 -20
  8. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/episode_padding_wrapper.py +16 -19
  9. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/helpers.py +56 -6
  10. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/image_wrapper.py +39 -9
  11. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/standardize_env_wrapper.py +12 -5
  12. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/standardize_wrapper.py +1 -33
  13. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/time_limit_wrapper.py +18 -3
  14. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/utils.py +28 -18
  15. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/vector.py +4 -6
  16. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/pyproject.toml +4 -1
  17. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/.gitignore +0 -0
  18. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/LICENSE +0 -0
  19. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/memory_trace.py +0 -0
  20. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/__init__.py +0 -0
  21. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/auto_batched_wrapper.py +0 -0
  22. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/flatten_obs_wrapper.py +0 -0
  23. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/mocks.py +0 -0
  24. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/spaces.py +0 -0
  25. {env_ssl_wrapper-0.4.1 → env_ssl_wrapper-0.4.3}/env_ssl_wrapper/standardize/tensor_wrapper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-ssl-wrapper
3
- Version: 0.4.1
3
+ Version: 0.4.3
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
@@ -47,14 +47,17 @@ Requires-Dist: fire; extra == 'examples'
47
47
  Provides-Extra: test
48
48
  Requires-Dist: box2d-py>=2.3.5; extra == 'test'
49
49
  Requires-Dist: dm-control; extra == 'test'
50
+ Requires-Dist: gym-pusht; extra == 'test'
50
51
  Requires-Dist: gymnasium-robotics; extra == 'test'
51
52
  Requires-Dist: gymnasium>=0.29.1; extra == 'test'
52
53
  Requires-Dist: imageio; extra == 'test'
54
+ Requires-Dist: mean-conc-beta; extra == 'test'
53
55
  Requires-Dist: mujoco; extra == 'test'
54
56
  Requires-Dist: pillow; extra == 'test'
55
57
  Requires-Dist: populora; extra == 'test'
56
58
  Requires-Dist: pybullet; extra == 'test'
57
59
  Requires-Dist: pygame; extra == 'test'
60
+ Requires-Dist: pymunk<7; extra == 'test'
58
61
  Requires-Dist: pytest; extra == 'test'
59
62
  Description-Content-Type: text/markdown
60
63
 
@@ -81,7 +84,7 @@ env = StandardizeEnvWrapper(any_env)
81
84
  obs, info = env.reset() # torch.float32, batched
82
85
 
83
86
  while not env.all_done:
84
- actions = torch.randint(0, 2, (8,))
87
+ actions = torch.randint(0, 2, (1,))
85
88
  obs, reward, terminated, truncated, info = env.step(actions)
86
89
  ```
87
90
 
@@ -131,6 +134,53 @@ Run the PPO benchmark on POMDP LunarLander:
131
134
  uv run test_memory_trace.py
132
135
  ```
133
136
 
137
+ ### Action Chunk
138
+
139
+ Open-loop action chunking: one `step` executes a whole chunk of actions and returns only the final state, temporally compressing the environment by the chunk length.
140
+
141
+ ```python
142
+ import gymnasium as gym
143
+ import torch
144
+ from env_ssl_wrapper import StandardizeEnvWrapper, ActionChunkWrapper
145
+
146
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
147
+ env = ActionChunkWrapper(env, chunk_len = 2, gamma = 0.99)
148
+
149
+ obs, info = env.reset()
150
+
151
+ while True:
152
+ actions = torch.randint(0, 2, (1, 2)) # (num_envs, chunk_len)
153
+ obs, reward, terminated, truncated, info = env.step(actions)
154
+
155
+ # reward -> discounted sum of substeps (r0 + gamma * r1 + ...)
156
+ # info['discount'] -> gamma ** chunk_length (macro-step discount for GAE / Bellman target)
157
+ # info['chunk_length'] -> env steps actually executed (drops below chunk_len on terminal chunk)
158
+ # info['chunk_rewards'] -> (1, chunk_length) raw per-step rewards
159
+
160
+ if bool((terminated | truncated).item()):
161
+ obs, info = env.reset()
162
+ ```
163
+
164
+ Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions).
165
+
166
+ 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
+
168
+ Pass `gamma` (default `1.`) to discount intra-chunk rewards $r = \sum_{i=0}^{L-1} \gamma^i r_i$. The macro-transition discount factor to the next state is provided as `info['discount'] = gamma ** chunk_length`. `reward_mode` can also be `'mean'` or `'last'`.
169
+
170
+ Can also be passed directly to `StandardizeEnvWrapper` or `compose_env`:
171
+
172
+ ```python
173
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'), chunk_len = 2, chunk_gamma = 0.99)
174
+ ```
175
+
176
+ Run the chunked PPO benchmark on CartPole, or check chunked rollouts against a per-step reference:
177
+
178
+ ```bash
179
+ uv run test_action_chunk.py --chunk_len=2 # PPO on CartPole
180
+ uv run test_action_chunk.py --sweep=True # PPO over several chunk lengths
181
+ uv run test_action_chunk.py --verify=True --sweep=True # chunked rollouts match per-step reference
182
+ ```
183
+
134
184
  ## Wrappers
135
185
 
136
186
  Pass wrappers as strings (default config) or `(name, dict)` tuples (custom config), in any order.
@@ -145,6 +195,7 @@ Pass wrappers as strings (default config) or `(name, dict)` tuples (custom confi
145
195
  | `action_transform` | Rescales actions from a canonical `(0, 1)` range to the env's bounds. |
146
196
  | `tensor` | NumPy → torch on a device, torch actions → numpy for the sim. |
147
197
  | `flatten_obs` | Flattens dict/tuple observations into a single vector. |
198
+ | `action_chunk` | Executes actions in open-loop chunks of length k. `('action_chunk', dict(chunk_len = 2, gamma = 0.99))` |
148
199
 
149
200
  Every env emits the same contract: obs `torch.float32`, rewards `torch.float32`, `terminated`/`truncated` `torch.bool`. `env.seed(n)` works on every sim.
150
201
 
@@ -21,7 +21,7 @@ env = StandardizeEnvWrapper(any_env)
21
21
  obs, info = env.reset() # torch.float32, batched
22
22
 
23
23
  while not env.all_done:
24
- actions = torch.randint(0, 2, (8,))
24
+ actions = torch.randint(0, 2, (1,))
25
25
  obs, reward, terminated, truncated, info = env.step(actions)
26
26
  ```
27
27
 
@@ -71,6 +71,53 @@ Run the PPO benchmark on POMDP LunarLander:
71
71
  uv run test_memory_trace.py
72
72
  ```
73
73
 
74
+ ### Action Chunk
75
+
76
+ Open-loop action chunking: one `step` executes a whole chunk of actions and returns only the final state, temporally compressing the environment by the chunk length.
77
+
78
+ ```python
79
+ import gymnasium as gym
80
+ import torch
81
+ from env_ssl_wrapper import StandardizeEnvWrapper, ActionChunkWrapper
82
+
83
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
84
+ env = ActionChunkWrapper(env, chunk_len = 2, gamma = 0.99)
85
+
86
+ obs, info = env.reset()
87
+
88
+ while True:
89
+ actions = torch.randint(0, 2, (1, 2)) # (num_envs, chunk_len)
90
+ obs, reward, terminated, truncated, info = env.step(actions)
91
+
92
+ # reward -> discounted sum of substeps (r0 + gamma * r1 + ...)
93
+ # info['discount'] -> gamma ** chunk_length (macro-step discount for GAE / Bellman target)
94
+ # info['chunk_length'] -> env steps actually executed (drops below chunk_len on terminal chunk)
95
+ # info['chunk_rewards'] -> (1, chunk_length) raw per-step rewards
96
+
97
+ if bool((terminated | truncated).item()):
98
+ obs, info = env.reset()
99
+ ```
100
+
101
+ Chunks are shaped `(num_envs, chunk_len, *action_shape)` (`(num_envs, chunk_len)` for discrete actions).
102
+
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
+
105
+ Pass `gamma` (default `1.`) to discount intra-chunk rewards $r = \sum_{i=0}^{L-1} \gamma^i r_i$. The macro-transition discount factor to the next state is provided as `info['discount'] = gamma ** chunk_length`. `reward_mode` can also be `'mean'` or `'last'`.
106
+
107
+ Can also be passed directly to `StandardizeEnvWrapper` or `compose_env`:
108
+
109
+ ```python
110
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'), chunk_len = 2, chunk_gamma = 0.99)
111
+ ```
112
+
113
+ Run the chunked PPO benchmark on CartPole, or check chunked rollouts against a per-step reference:
114
+
115
+ ```bash
116
+ uv run test_action_chunk.py --chunk_len=2 # PPO on CartPole
117
+ uv run test_action_chunk.py --sweep=True # PPO over several chunk lengths
118
+ uv run test_action_chunk.py --verify=True --sweep=True # chunked rollouts match per-step reference
119
+ ```
120
+
74
121
  ## Wrappers
75
122
 
76
123
  Pass wrappers as strings (default config) or `(name, dict)` tuples (custom config), in any order.
@@ -85,6 +132,7 @@ Pass wrappers as strings (default config) or `(name, dict)` tuples (custom confi
85
132
  | `action_transform` | Rescales actions from a canonical `(0, 1)` range to the env's bounds. |
86
133
  | `tensor` | NumPy → torch on a device, torch actions → numpy for the sim. |
87
134
  | `flatten_obs` | Flattens dict/tuple observations into a single vector. |
135
+ | `action_chunk` | Executes actions in open-loop chunks of length k. `('action_chunk', dict(chunk_len = 2, gamma = 0.99))` |
88
136
 
89
137
  Every env emits the same contract: obs `torch.float32`, rewards `torch.float32`, `terminated`/`truncated` `torch.bool`. `env.seed(n)` works on every sim.
90
138
 
@@ -5,6 +5,7 @@ from . import standardize
5
5
  from .standardize import *
6
6
 
7
7
  from .memory_trace import MemoryTraceWrapper
8
+ from .action_chunk import ActionChunkWrapper
8
9
 
9
10
  # Wire backwards-compatibility aliases in sys.modules and module globals
10
11
  # so imports like `from env_ssl_wrapper.done_tracker_wrapper import DoneTrackerWrapper`
@@ -38,6 +39,7 @@ for _name in _STANDARDIZE_SUBMODULES:
38
39
  __all__ = [
39
40
  *standardize.__all__,
40
41
  'MemoryTraceWrapper',
42
+ 'ActionChunkWrapper',
41
43
  ]
42
44
 
43
45
  def __getattr__(name):
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+ from einops import reduce
6
+ from torch import is_tensor
7
+
8
+ from .standardize.helpers import (
9
+ EnvWrapper,
10
+ exists,
11
+ default,
12
+ any_true,
13
+ dones_of,
14
+ get_attr,
15
+ )
16
+
17
+ # helpers
18
+
19
+ def stack_steps(steps):
20
+ return torch.stack(steps, dim = -1) if is_tensor(steps[0]) else np.stack(steps, axis = -1)
21
+
22
+ def chunk_steps(actions, axis):
23
+ return actions.unbind(dim = axis) if is_tensor(actions) else np.moveaxis(actions, axis, 0)
24
+
25
+ # wrapper
26
+
27
+ class ActionChunkWrapper(EnvWrapper):
28
+ """
29
+ Open-loop action chunking - executes a chunk of actions in one step,
30
+ temporally compressing the environment by the chunk length.
31
+
32
+ action chunks are shaped (num_envs, chunk_len, *action_shape)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ env,
38
+ chunk_len: int,
39
+ gamma: float = 1.,
40
+ discount: float | None = None,
41
+ reward_mode: str = 'sum'
42
+ ):
43
+ super().__init__(env)
44
+
45
+ gamma = default(discount, gamma)
46
+ assert chunk_len >= 1, f'chunk_len must be at least 1, got {chunk_len}'
47
+ assert 0. <= gamma <= 1., f'gamma must be between 0 and 1, got {gamma}'
48
+ assert reward_mode in ('sum', 'mean', 'last'), f'unknown reward_mode {reward_mode!r}'
49
+
50
+ self.chunk_len = chunk_len
51
+ self.gamma = float(gamma)
52
+ self.reward_mode = reward_mode
53
+
54
+ action_space = get_attr(env, 'action_space')
55
+ self.action_shape = tuple(get_attr(action_space, 'shape', ()) or ())
56
+
57
+ @property
58
+ def chunk_action_shape(self):
59
+ return (self.chunk_len, *self.action_shape)
60
+
61
+ def reset(self, **kwargs):
62
+ return self.env.reset(**kwargs)
63
+
64
+ def step(self, actions):
65
+ if isinstance(actions, dict):
66
+ raise NotImplementedError('action chunking does not support dict actions yet')
67
+
68
+ if not (is_tensor(actions) or isinstance(actions, np.ndarray)):
69
+ actions = np.asarray(actions)
70
+
71
+ chunk_axis = actions.ndim - 1 - len(self.action_shape)
72
+
73
+ assert chunk_axis >= 1, (
74
+ f'action chunks must be shaped (num_envs, chunk_len, *action_shape), got {tuple(actions.shape)}'
75
+ )
76
+
77
+ assert actions.shape[chunk_axis] == self.chunk_len, (
78
+ f'expected chunk length {self.chunk_len}, got {actions.shape[chunk_axis]}'
79
+ )
80
+
81
+ rewards = []
82
+ out = None
83
+
84
+ for action in chunk_steps(actions, chunk_axis):
85
+ out = self.env.step(action)
86
+ rewards.append(out[1])
87
+
88
+ if any_true(dones_of(out[2], out[3])):
89
+ break
90
+
91
+ obs, _, terminated, truncated, info = out
92
+
93
+ rewards = stack_steps(rewards)
94
+ executed_len = rewards.shape[-1]
95
+
96
+ if self.reward_mode == 'last':
97
+ reward = rewards[..., -1]
98
+ elif self.reward_mode == 'mean':
99
+ reward = reduce(rewards, '... k -> ...', 'mean')
100
+ elif self.gamma != 1.:
101
+ if is_tensor(rewards):
102
+ dtype = rewards.dtype if rewards.is_floating_point() else torch.float32
103
+ discounts = (self.gamma ** torch.arange(executed_len, device = rewards.device)).to(dtype = dtype)
104
+ else:
105
+ dtype = rewards.dtype if np.issubdtype(rewards.dtype, np.floating) else np.float32
106
+ discounts = (self.gamma ** np.arange(executed_len)).astype(dtype)
107
+
108
+ reward = reduce(rewards * discounts, '... k -> ...', 'sum')
109
+ else:
110
+ reward = reduce(rewards, '... k -> ...', 'sum')
111
+
112
+ info = dict(info) if isinstance(info, dict) else {}
113
+ info['chunk_length'] = executed_len
114
+ info['chunk_rewards'] = rewards
115
+ info['discount'] = self.gamma ** executed_len
116
+
117
+ return obs, reward, terminated, truncated, info
@@ -97,9 +97,14 @@ class ActionTransformWrapper(EnvWrapper):
97
97
  if was_scalar:
98
98
  t = rearrange(t, '-> 1')
99
99
 
100
- dim = t.shape[-1]
101
- low = np.broadcast_to(low, (dim,))
102
- high = np.broadcast_to(high, (dim,))
100
+ # bounds may be unbatched (single_action_space) or already batched
101
+ # (vector envs exposing only a batched action_space)
102
+
103
+ if low.shape != t.shape and not (low.ndim > 0 and t.shape[-low.ndim:] == low.shape):
104
+ dim = t.shape[-1]
105
+ low = np.broadcast_to(low, (dim,))
106
+ high = np.broadcast_to(high, (dim,))
107
+
103
108
  valid = np.isfinite(low) & np.isfinite(high)
104
109
 
105
110
  if is_tensor(t):
@@ -1,32 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- import numpy as np
4
- import torch
5
- from torch import is_tensor
6
-
7
3
  from .helpers import (
8
4
  EnvWrapper,
9
5
  default,
10
6
  exists,
11
7
  first_existing,
12
8
  get_attr,
9
+ is_time_step,
10
+ normalize_reset_out,
11
+ normalize_step_out,
13
12
  safe_close,
14
13
  truthy_attr,
14
+ zero_like,
15
15
  )
16
16
  from .spaces import space_from_action_spec
17
17
 
18
- # helpers
19
-
20
- def zero_like(x):
21
- if is_tensor(x):
22
- return torch.zeros_like(x, dtype = torch.bool)
23
-
24
- arr = np.asarray(x)
25
- return np.zeros_like(arr, dtype = bool) if arr.ndim > 0 else False
26
-
27
- def is_time_step(out):
28
- return exists(get_attr(out, 'step_type')) and exists(get_attr(out, 'observation'))
29
-
30
18
  # base adapter
31
19
 
32
20
  class BaseEnvAdapter:
@@ -38,27 +26,10 @@ class BaseEnvAdapter:
38
26
  self.env = env
39
27
 
40
28
  def step(self, action) -> tuple:
41
- out = self.env.step(action)
42
- if is_time_step(out):
43
- last = out.last() if callable(get_attr(out, 'last')) else out.step_type == 2
44
- return out.observation, out.reward, last, False, dict(discount = out.discount)
45
- if len(out) == 5:
46
- obs, reward, term, trunc, info = out
47
- return obs, reward, term, trunc, info if isinstance(info, dict) else {}
48
- if len(out) in (3, 4):
49
- obs, reward, done, *rest = out
50
- info = rest[0] if rest and isinstance(rest[0], dict) else {}
51
- return obs, reward, done, zero_like(done), info
52
- raise ValueError(f'cannot standardize step output of length {len(out)}')
29
+ return normalize_step_out(self.env.step(action))
53
30
 
54
31
  def reset(self, **kwargs) -> tuple:
55
- out = self.env.reset(**kwargs)
56
- if is_time_step(out):
57
- return out.observation, {}
58
- if isinstance(out, tuple) and len(out) == 2:
59
- obs, info = out
60
- return obs, {} if info is None else (info if isinstance(info, dict) else {})
61
- return out, {}
32
+ return normalize_reset_out(self.env.reset(**kwargs))
62
33
 
63
34
  def seed(self, seed: int):
64
35
  if callable(get_attr(self.env, 'seed')):
@@ -381,15 +352,16 @@ class GymnasiumAdapter(BaseEnvAdapter):
381
352
 
382
353
  @property
383
354
  def autoresets(self) -> bool:
384
- if truthy_attr(first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')):
385
- return True
355
+ mode = first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')
356
+
357
+ if exists(mode):
358
+ return truthy_attr(mode)
359
+
386
360
  try:
387
361
  from gymnasium.vector import VectorEnv
388
- if isinstance(self.env, VectorEnv):
389
- return True
362
+ return isinstance(self.env, VectorEnv)
390
363
  except ImportError:
391
- pass
392
- return False
364
+ return False
393
365
 
394
366
  def seed(self, seed: int):
395
367
  try:
@@ -2,8 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import numpy as np
4
4
 
5
- from torch.utils._pytree import tree_flatten
6
-
7
5
  from .auto_batched_wrapper import AutoBatchedWrapper
8
6
  from .helpers import (
9
7
  EnvWrapper,
@@ -11,27 +9,11 @@ from .helpers import (
11
9
  env_autoresets,
12
10
  env_num_envs,
13
11
  exists,
14
- get_attr,
15
- is_array,
12
+ get_batch_size,
16
13
  is_vectorized,
17
14
  to_numpy,
18
15
  )
19
16
 
20
- # helper functions
21
-
22
- def get_batch_size(tree) -> int | None:
23
- leaves, _ = tree_flatten(tree)
24
-
25
- if not leaves:
26
- return None
27
-
28
- first = leaves[0]
29
-
30
- if is_array(first):
31
- return len(first) if first.ndim > 0 else None
32
-
33
- return len(first) if exists(get_attr(first, '__len__')) else None
34
-
35
17
  # classes
36
18
 
37
19
  class DoneTrackerWrapper(EnvWrapper):
@@ -113,6 +95,6 @@ class DoneTrackerWrapper(EnvWrapper):
113
95
  info['episode_lengths'] = self.episode_lengths.copy()
114
96
 
115
97
  if self.all_done:
116
- info.update(needs_reset = True, all_done = True)
98
+ info.update(needs_reset = self.needs_reset, all_done = True)
117
99
 
118
100
  return obs, reward, terminated, truncated, info
@@ -21,15 +21,18 @@ from .helpers import (
21
21
 
22
22
  # helpers
23
23
 
24
- def zero_mask(x, mask, fill_scalar = None):
25
- if is_tensor(x):
26
- m = torch.as_tensor(mask, device = x.device, dtype = torch.bool)
27
- diff = x.ndim - m.ndim
24
+ def broadcast_mask(mask, x):
25
+ if not is_tensor(x):
26
+ return mask
27
+
28
+ m = torch.as_tensor(mask, device = x.device, dtype = torch.bool)
29
+ diff = x.ndim - m.ndim
28
30
 
29
- if diff > 0:
30
- m = rearrange(m, f'... -> ... {" ".join(["1"] * diff)}')
31
+ return rearrange(m, f'... -> ... {" ".join(["1"] * diff)}') if diff > 0 else m
31
32
 
32
- return torch.where(m, torch.zeros_like(x), x)
33
+ def zero_mask(x, mask, fill_scalar = None):
34
+ if is_tensor(x):
35
+ return torch.where(broadcast_mask(mask, x), torch.zeros_like(x), x)
33
36
 
34
37
  arr = np.asarray(x)
35
38
 
@@ -49,13 +52,7 @@ def back_to_mask_type(dones, newly):
49
52
 
50
53
  def merge_final(current, value, mask):
51
54
  if is_tensor(current):
52
- m = torch.as_tensor(mask, device = current.device, dtype = torch.bool)
53
- diff = current.ndim - m.ndim
54
-
55
- if diff > 0:
56
- m = rearrange(m, f'... -> ... {" ".join(["1"] * diff)}')
57
-
58
- return torch.where(m, value, current)
55
+ return torch.where(broadcast_mask(mask, current), value, current)
59
56
 
60
57
  curr = np.asarray(current)
61
58
 
@@ -97,15 +94,15 @@ class EpisodePaddingWrapper(EnvWrapper):
97
94
  if self._is_done is None or len(self._is_done) != len(mask):
98
95
  self._is_done = np.zeros(len(mask), dtype = bool)
99
96
 
100
- if self.autoreset:
101
- self._is_done &= mask
97
+ # autoreset envs revive every done slot, so each done is a new
98
+ # terminal transition; non-autoreset slots stay done until reset
99
+
100
+ newly = mask if self.autoreset else mask & ~self._is_done
101
+ self._is_done |= mask
102
102
 
103
103
  if mask.any():
104
104
  assert exists(self._last_obs), 'environment needs reset before calling step. call env.reset() first'
105
105
 
106
- newly = mask & ~self._is_done
107
- self._is_done |= mask
108
-
109
106
  if newly.any():
110
107
  final_val = first_existing(info, 'final_observation', 'final_obs')
111
108
  value = final_val if exists(final_val) else self._last_obs
@@ -22,10 +22,15 @@ def get_attr(obj, name, default = None):
22
22
  return default
23
23
 
24
24
  def truthy_attr(value):
25
- # flags arrive as None, methods, numpy scalars — only honest truths count
25
+ # flags arrive as None, methods, numpy scalars — only honest truths count.
26
+ # gymnasium's AutoresetMode.DISABLED is a truthy enum that means "off"
27
+
26
28
  if not exists(value) or callable(value):
27
29
  return False
28
30
 
31
+ if getattr(value, 'name', None) == 'DISABLED':
32
+ return False
33
+
29
34
  try:
30
35
  return bool(value)
31
36
  except Exception:
@@ -46,6 +51,19 @@ def first_existing(obj, *names):
46
51
  def is_scalar(v):
47
52
  return isinstance(v, (int, float, bool, np.number, np.bool_))
48
53
 
54
+ def get_batch_size(tree) -> int | None:
55
+ leaves, _ = tree_flatten(tree)
56
+
57
+ if not leaves:
58
+ return None
59
+
60
+ first = leaves[0]
61
+
62
+ if is_array(first):
63
+ return len(first) if first.ndim > 0 else None
64
+
65
+ return len(first) if exists(get_attr(first, '__len__')) else None
66
+
49
67
  def is_array(v):
50
68
  return is_tensor(v) or isinstance(v, np.ndarray)
51
69
 
@@ -71,6 +89,43 @@ def dones_of(terminated, truncated):
71
89
  return terminated | truncated
72
90
  return tree_map(lambda a, b: a | b, terminated, truncated)
73
91
 
92
+ # sim step / reset normalization
93
+
94
+ def is_time_step(out):
95
+ return exists(get_attr(out, 'step_type')) and exists(get_attr(out, 'observation'))
96
+
97
+ def zero_like(x):
98
+ if is_tensor(x):
99
+ return torch.zeros_like(x, dtype = torch.bool)
100
+
101
+ arr = np.asarray(x)
102
+ return np.zeros_like(arr, dtype = bool) if arr.ndim > 0 else False
103
+
104
+ def normalize_reset_out(out):
105
+ if is_time_step(out):
106
+ return out.observation, {}
107
+
108
+ if isinstance(out, tuple) and len(out) == 2:
109
+ obs, info = out
110
+ return obs, {} if info is None else (info if isinstance(info, dict) else {})
111
+
112
+ return out, {}
113
+
114
+ def normalize_step_out(out):
115
+ if is_time_step(out):
116
+ last = out.last() if callable(get_attr(out, 'last')) else out.step_type == 2
117
+ return out.observation, out.reward, last, False, dict(discount = out.discount)
118
+
119
+ if len(out) == 5:
120
+ return out
121
+
122
+ if len(out) in (3, 4):
123
+ obs, reward, done, *rest = out
124
+ info = rest[0] if rest and isinstance(rest[0], dict) else {}
125
+ return obs, reward, done, zero_like(done), info
126
+
127
+ raise ValueError(f'could not standardize step output of length {len(out)}')
128
+
74
129
  def _zero_leaf(x):
75
130
  if is_tensor(x):
76
131
  return torch.zeros_like(x)
@@ -272,16 +327,11 @@ class TransformObservationWrapper(EnvWrapper):
272
327
  self.takes_done = accepts_done_param(self.transform_obs)
273
328
 
274
329
  def transform_obs(self, obs, done = None):
275
- if hasattr(self, 'observation'):
276
- return self.observation(obs)
277
330
  return obs
278
331
 
279
332
  def transform(self, obs, done = None):
280
333
  return self.transform_obs(obs, done = done) if self.takes_done else self.transform_obs(obs)
281
334
 
282
- def observation(self, obs):
283
- return self.transform_obs(obs)
284
-
285
335
  def reset(self, **kwargs):
286
336
  obs, info = self.env.reset(**kwargs)
287
337
  obs = self.transform_obs(obs)
@@ -6,8 +6,15 @@ import numpy as np
6
6
 
7
7
  from einops import rearrange
8
8
 
9
- from .helpers import EnvWrapper, env_render, env_render_mode, exists
10
- from .standardize_wrapper import normalize_reset_out, normalize_step_out
9
+ from .helpers import (
10
+ FINAL_OBSERVATION_KEYS,
11
+ TransformObservationWrapper,
12
+ env_render,
13
+ env_render_mode,
14
+ exists,
15
+ normalize_reset_out,
16
+ normalize_step_out,
17
+ )
11
18
 
12
19
  # helper functions
13
20
 
@@ -32,12 +39,19 @@ def render_frame(env, image_size = (64, 64), camera = None):
32
39
 
33
40
  img = env.render()
34
41
 
42
+ if isinstance(img, (list, tuple)):
43
+ img = np.ascontiguousarray(img)
44
+
35
45
  if isinstance(img, torch.Tensor):
36
46
  img = img.detach().cpu().numpy()
37
47
 
38
48
  img = np.ascontiguousarray(img)
39
49
  img = torch.from_numpy(img)
40
- return rearrange(img, 'h w c -> 1 c h w')
50
+
51
+ if img.ndim == 4:
52
+ return rearrange(img, 'b h w c -> b c h w'), True
53
+
54
+ return rearrange(img, 'h w c -> 1 c h w'), False
41
55
 
42
56
  def process_image(
43
57
  img,
@@ -61,7 +75,7 @@ def process_image(
61
75
 
62
76
  # class
63
77
 
64
- class ImageObservationWrapper(EnvWrapper):
78
+ class ImageObservationWrapper(TransformObservationWrapper):
65
79
  def __init__(
66
80
  self,
67
81
  env,
@@ -81,18 +95,21 @@ class ImageObservationWrapper(EnvWrapper):
81
95
  self.normalize_divisor = normalize_divisor
82
96
 
83
97
  def render_frame(self):
84
- img = render_frame(self.env, image_size = self.image_size, camera = self.camera)
85
- return process_image(
98
+ img, is_batched = render_frame(self.env, image_size = self.image_size, camera = self.camera)
99
+ processed = process_image(
86
100
  img,
87
101
  image_size = self.image_size,
88
102
  mode = self.mode,
89
103
  normalize = self.normalize,
90
104
  normalize_divisor = self.normalize_divisor
91
105
  )
106
+ return processed, is_batched
92
107
 
93
108
  def observation(self, obs):
94
- img = self.render_frame()
95
- img = rearrange(img, '1 c h w -> c h w')
109
+ img, is_batched = self.render_frame()
110
+
111
+ if not is_batched:
112
+ img = rearrange(img, '1 c h w -> c h w')
96
113
 
97
114
  if not isinstance(obs, dict):
98
115
  return dict(state = obs, **{self.image_key: img})
@@ -102,10 +119,23 @@ class ImageObservationWrapper(EnvWrapper):
102
119
 
103
120
  return {**obs, self.image_key: img}
104
121
 
122
+ def transform_obs(self, obs, done = None):
123
+ return self.observation(obs)
124
+
105
125
  def reset(self, **kwargs):
106
126
  obs, info = normalize_reset_out(self.env.reset(**kwargs))
107
127
  return self.observation(obs), info
108
128
 
109
129
  def step(self, action):
110
130
  obs, reward, terminated, truncated, info = normalize_step_out(self.env.step(action))
111
- return self.observation(obs), reward, terminated, truncated, info
131
+ out = self.observation(obs)
132
+
133
+ # terminal obs are augmented in place, preserving whatever the sim
134
+ # or the padding wrapper froze as the true terminal observation
135
+
136
+ if isinstance(info, dict) and not self.autoresets:
137
+ for key in FINAL_OBSERVATION_KEYS:
138
+ if key in info and not (isinstance(info[key], dict) and self.image_key in info[key]):
139
+ info[key] = self.observation(info[key])
140
+
141
+ return out, reward, terminated, truncated, info
@@ -18,11 +18,14 @@ class StandardizeEnvWrapper(EnvWrapper):
18
18
  pad_episodes: bool = True,
19
19
  done_tracker: bool = True,
20
20
  flatten_obs: bool = False,
21
- action_transform: bool = False,
21
+ action_transform: bool | dict = False,
22
22
  max_timesteps: int | None = None,
23
- image_size: tuple[int, int] | None = None,
23
+ image_size: int | tuple[int, int] | None = None,
24
24
  lambdas: tuple[float, ...] | list[float] | None = None,
25
- **kwargs
25
+ keys: str | tuple[str, ...] | None = None,
26
+ chunk_len: int | None = None,
27
+ chunk_gamma: float = 1.,
28
+ chunk_reward_mode: str = 'sum',
26
29
  ):
27
30
  wrappers = []
28
31
 
@@ -30,7 +33,8 @@ class StandardizeEnvWrapper(EnvWrapper):
30
33
  wrappers.append(('image', dict(image_size = image_size)))
31
34
 
32
35
  if action_transform:
33
- wrappers.append('action_transform')
36
+ transform_kwargs = dict(auto = True) if action_transform is True else action_transform
37
+ wrappers.append(('action_transform', transform_kwargs))
34
38
 
35
39
  if exists(max_timesteps):
36
40
  wrappers.append(('time_limit', dict(max_timesteps = max_timesteps)))
@@ -45,7 +49,10 @@ class StandardizeEnvWrapper(EnvWrapper):
45
49
  wrappers.append('done_tracker')
46
50
 
47
51
  if exists(lambdas):
48
- wrappers.append(('memory_trace', dict(lambdas = lambdas, **kwargs)))
52
+ wrappers.append(('memory_trace', dict(lambdas = lambdas, keys = keys)))
53
+
54
+ if exists(chunk_len):
55
+ wrappers.append(('action_chunk', dict(chunk_len = chunk_len, gamma = chunk_gamma, reward_mode = chunk_reward_mode)))
49
56
 
50
57
  if flatten_obs:
51
58
  wrappers.append('flatten_obs')
@@ -1,48 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- import numpy as np
4
- import torch
5
- from torch import is_tensor
6
-
7
- from .adapters import get_adapter, is_time_step, zero_like
3
+ from .adapters import get_adapter
8
4
  from .helpers import (
9
5
  EnvWrapper,
10
6
  default,
11
7
  dones_of,
12
8
  exists,
13
- get_attr,
14
9
  instantiate_env,
15
10
  mark_terminal_obs,
16
11
  )
17
12
  from .spaces import infer_observation_space, space_from_action_spec
18
13
 
19
- # helpers
20
-
21
- def normalize_reset_out(out):
22
- if is_time_step(out):
23
- return out.observation, {}
24
-
25
- if isinstance(out, tuple) and len(out) == 2:
26
- obs, info = out
27
- return obs, {} if info is None else (info if isinstance(info, dict) else {})
28
-
29
- return out, {}
30
-
31
- def normalize_step_out(out):
32
- if is_time_step(out):
33
- last = out.last() if callable(get_attr(out, 'last')) else out.step_type == 2
34
- return out.observation, out.reward, last, False, dict(discount = out.discount)
35
-
36
- if len(out) == 5:
37
- return out
38
-
39
- if len(out) in (3, 4):
40
- obs, reward, done, *rest = out
41
- info = rest[0] if rest and isinstance(rest[0], dict) else {}
42
- return obs, reward, done, zero_like(done), info
43
-
44
- raise ValueError(f'could not standardize step output of length {len(out)}')
45
-
46
14
  # class
47
15
 
48
16
  class StandardizeWrapper(EnvWrapper):
@@ -4,8 +4,18 @@ import numpy as np
4
4
  import torch
5
5
  from torch import is_tensor
6
6
 
7
- from .helpers import EnvWrapper, dones_of, env_num_envs, is_vectorized, mark_terminal_obs, to_numpy
8
- from .standardize_wrapper import normalize_reset_out, normalize_step_out
7
+ from .helpers import (
8
+ EnvWrapper,
9
+ dones_of,
10
+ env_num_envs,
11
+ exists,
12
+ get_batch_size,
13
+ is_vectorized,
14
+ mark_terminal_obs,
15
+ normalize_reset_out,
16
+ normalize_step_out,
17
+ to_numpy,
18
+ )
9
19
 
10
20
  def back_to_like(t, numpy_arr):
11
21
  if is_tensor(t):
@@ -44,8 +54,13 @@ class TimeLimitWrapper(EnvWrapper):
44
54
  self.t = np.zeros(self.num_envs, dtype = int)
45
55
 
46
56
  def reset(self, **kwargs):
57
+ obs, info = normalize_reset_out(self.env.reset(**kwargs))
58
+
59
+ if self.is_vector and exists(batch_size := get_batch_size(obs)):
60
+ self.num_envs = batch_size
61
+
47
62
  self.t = np.zeros(self.num_envs, dtype = int)
48
- return normalize_reset_out(self.env.reset(**kwargs))
63
+ return obs, info
49
64
 
50
65
  def step(self, action):
51
66
  obs, reward, terminated, truncated, info = normalize_step_out(self.env.step(action))
@@ -25,29 +25,31 @@ WRAPPERS = dict(
25
25
  pad_episodes = EpisodePaddingWrapper
26
26
  )
27
27
 
28
+ def get_wrapper(name):
29
+ if name in ('standardize_env', 'master'):
30
+ from .standardize_env_wrapper import StandardizeEnvWrapper
31
+ return StandardizeEnvWrapper
32
+
33
+ if name == 'memory_trace':
34
+ from ..memory_trace import MemoryTraceWrapper
35
+ return MemoryTraceWrapper
36
+
37
+ if name in ('action_chunk', 'chunk'):
38
+ from ..action_chunk import ActionChunkWrapper
39
+ return ActionChunkWrapper
40
+
41
+ if name in WRAPPERS:
42
+ return WRAPPERS[name]
43
+
44
+ raise ValueError(f'unknown wrapper {name!r} — choose from {sorted([*WRAPPERS, "memory_trace", "action_chunk", "standardize_env"])}')
45
+
28
46
  def parse_wrapper(wrapper):
29
47
  if isinstance(wrapper, str):
30
- if wrapper == 'memory_trace':
31
- from ..memory_trace import MemoryTraceWrapper
32
- wrapper = MemoryTraceWrapper
33
- elif wrapper in ('standardize_env', 'master'):
34
- from .standardize_env_wrapper import StandardizeEnvWrapper
35
- wrapper = StandardizeEnvWrapper
36
- elif wrapper not in WRAPPERS:
37
- raise ValueError(f'unknown wrapper {wrapper!r} — choose from {sorted([*WRAPPERS, "memory_trace", "standardize_env"])}')
38
- else:
39
- wrapper = WRAPPERS[wrapper]
48
+ wrapper = get_wrapper(wrapper)
40
49
 
41
50
  if isinstance(wrapper, tuple):
42
51
  name, kwargs = wrapper
43
- if name == 'memory_trace':
44
- from ..memory_trace import MemoryTraceWrapper
45
- wrapper = partial(MemoryTraceWrapper, **kwargs)
46
- elif name in ('standardize_env', 'master'):
47
- from .standardize_env_wrapper import StandardizeEnvWrapper
48
- wrapper = partial(StandardizeEnvWrapper, **kwargs)
49
- else:
50
- wrapper = partial(WRAPPERS.get(name, name), **kwargs)
52
+ wrapper = partial(get_wrapper(name) if isinstance(name, str) else name, **kwargs)
51
53
 
52
54
  elif isinstance(wrapper, dict):
53
55
  raise ValueError("wrapper kwargs must be passed as (name, kwargs), e.g. ('tensor', dict(device = 'cpu'))")
@@ -89,6 +91,14 @@ def compose_env(env, *wrappers, pad_episodes: bool = True):
89
91
  funcs.insert(idx_ten + 1, f)
90
92
  classes.insert(idx_ten + 1, c)
91
93
 
94
+ # action chunking changes the step signature, so it always goes outermost
95
+
96
+ from ..action_chunk import ActionChunkWrapper
97
+ if ActionChunkWrapper in classes:
98
+ idx_chunk = classes.index(ActionChunkWrapper)
99
+ funcs.append(funcs.pop(idx_chunk))
100
+ classes.append(classes.pop(idx_chunk))
101
+
92
102
  assert len(set(classes)) == len(classes), 'duplicate wrappers found'
93
103
 
94
104
  for func in funcs:
@@ -22,8 +22,6 @@ from .standardize_wrapper import StandardizeWrapper
22
22
 
23
23
  # leaf helpers
24
24
 
25
- _stack_trees = stack_trees
26
-
27
25
  def _merge_infos(infos):
28
26
  infos = [info if isinstance(info, dict) else {} for info in infos]
29
27
 
@@ -38,7 +36,7 @@ def _merge_infos(infos):
38
36
  for key in keys:
39
37
  vals = [info[key] for info in infos]
40
38
  try:
41
- out[key] = _stack_trees(vals)
39
+ out[key] = stack_trees(vals)
42
40
  except Exception:
43
41
  out[key] = np.array(vals, dtype = object)
44
42
 
@@ -276,7 +274,7 @@ class MultiprocessingVecEnv:
276
274
  _safe_send(conn, ('reset', kwargs))
277
275
 
278
276
  results = _recv_all(self._conns)
279
- return _stack_trees([obs for obs, _ in results]), _merge_infos([info for _, info in results])
277
+ return stack_trees([obs for obs, _ in results]), _merge_infos([info for _, info in results])
280
278
 
281
279
  def step(self, actions):
282
280
  actions = _split_actions(actions, self.num_envs)
@@ -302,10 +300,10 @@ class MultiprocessingVecEnv:
302
300
  for final, obs_i in zip(final_obs, obs)
303
301
  ]
304
302
 
305
- info['final_observation'] = _stack_trees(final_obs)
303
+ info['final_observation'] = stack_trees(final_obs)
306
304
  info['_final_observation'] = done
307
305
 
308
- return _stack_trees(obs), reward, terminated, truncated, info
306
+ return stack_trees(obs), reward, terminated, truncated, info
309
307
 
310
308
  def close(self):
311
309
  _shutdown(self._conns, self._procs)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-ssl-wrapper"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "One torch-native interface for any MDP environment"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
@@ -54,6 +54,9 @@ test = [
54
54
  "pillow",
55
55
  "imageio",
56
56
  "populora",
57
+ "gym-pusht",
58
+ "pymunk<7",
59
+ "mean-conc-beta",
57
60
  ]
58
61
 
59
62
  [tool.pytest.ini_options]
File without changes