memmap-replay-buffer 0.2.0__tar.gz → 0.2.2__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.
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/PKG-INFO +22 -1
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/README.md +16 -0
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/__init__.py +4 -0
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/concat_replay_buffer.py +93 -0
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/episode_collector.py +284 -0
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend/index.html +87 -0
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend.py +118 -0
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/replay_buffer.py +571 -625
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/replay_buffer_h5py.py +233 -0
- memmap_replay_buffer-0.2.2/memmap_replay_buffer/video.py +163 -0
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/pyproject.toml +11 -2
- memmap_replay_buffer-0.2.2/tests/test_frontend.py +134 -0
- memmap_replay_buffer-0.2.2/tests/test_video.py +179 -0
- memmap_replay_buffer-0.2.0/memmap_replay_buffer/concat_replay_buffer.py +0 -94
- memmap_replay_buffer-0.2.0/memmap_replay_buffer/episode_collector.py +0 -105
- memmap_replay_buffer-0.2.0/memmap_replay_buffer/replay_buffer_h5py.py +0 -965
- memmap_replay_buffer-0.2.0/tests/test_e2e.py +0 -376
- memmap_replay_buffer-0.2.0/tests/test_edge_cases.py +0 -384
- memmap_replay_buffer-0.2.0/tests/test_episode_collector.py +0 -58
- memmap_replay_buffer-0.2.0/tests/test_get_all_data.py +0 -64
- memmap_replay_buffer-0.2.0/tests/test_lazy_init.py +0 -68
- memmap_replay_buffer-0.2.0/tests/test_mixed_storage.py +0 -81
- memmap_replay_buffer-0.2.0/tests/test_n_step.py +0 -205
- memmap_replay_buffer-0.2.0/tests/test_nstep_timestep_gotcha.py +0 -31
- memmap_replay_buffer-0.2.0/tests/test_replay_buffer.py +0 -764
- memmap_replay_buffer-0.2.0/tests/test_replay_buffer_h5py.py +0 -158
- memmap_replay_buffer-0.2.0/tests/test_scalar_flexibility.py +0 -45
- memmap_replay_buffer-0.2.0/tests/test_store_episode_parity.py +0 -71
- memmap_replay_buffer-0.2.0/tests/test_time_window.py +0 -51
- memmap_replay_buffer-0.2.0/tests/test_uint8_storage.py +0 -55
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/.gitignore +0 -0
- {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memmap-replay-buffer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Simple Replay Buffer for RL
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/memmap-replay-buffer/
|
|
6
6
|
Project-URL: Repository, https://codeberg.org/lucidrains/memmap-replay-buffer
|
|
@@ -40,7 +40,12 @@ Requires-Dist: numpy
|
|
|
40
40
|
Requires-Dist: torch>=2.4
|
|
41
41
|
Provides-Extra: test
|
|
42
42
|
Requires-Dist: h5py; extra == 'test'
|
|
43
|
+
Requires-Dist: imageio; extra == 'test'
|
|
44
|
+
Requires-Dist: imageio-ffmpeg; extra == 'test'
|
|
43
45
|
Requires-Dist: pytest; extra == 'test'
|
|
46
|
+
Provides-Extra: video
|
|
47
|
+
Requires-Dist: imageio; extra == 'video'
|
|
48
|
+
Requires-Dist: imageio-ffmpeg; extra == 'video'
|
|
44
49
|
Description-Content-Type: text/markdown
|
|
45
50
|
|
|
46
51
|
## memmap-replay-buffer
|
|
@@ -206,6 +211,22 @@ buffer.update(0, returns = returns_for_one) # scalar index
|
|
|
206
211
|
all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
|
|
207
212
|
```
|
|
208
213
|
|
|
214
|
+
### Rendering an episode to video
|
|
215
|
+
|
|
216
|
+
When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
|
|
223
|
+
|
|
224
|
+
The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
$ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
|
|
228
|
+
```
|
|
229
|
+
|
|
209
230
|
### Reopening, read-only, and clearing
|
|
210
231
|
|
|
211
232
|
`overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
|
|
@@ -161,6 +161,22 @@ buffer.update(0, returns = returns_for_one) # scalar index
|
|
|
161
161
|
all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### Rendering an episode to video
|
|
165
|
+
|
|
166
|
+
When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
|
|
173
|
+
|
|
174
|
+
The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
$ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
|
|
178
|
+
```
|
|
179
|
+
|
|
164
180
|
### Reopening, read-only, and clearing
|
|
165
181
|
|
|
166
182
|
`overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
|
|
@@ -10,3 +10,7 @@ try:
|
|
|
10
10
|
from memmap_replay_buffer.replay_buffer_h5py import ReplayBufferH5PY as ReplayBufferH5PY
|
|
11
11
|
except ImportError:
|
|
12
12
|
pass
|
|
13
|
+
|
|
14
|
+
from memmap_replay_buffer.episode_collector import EpisodeCollector as EpisodeCollector
|
|
15
|
+
from memmap_replay_buffer.video import frames_to_sheet as frames_to_sheet
|
|
16
|
+
from memmap_replay_buffer.video import trajectory_to_video as trajectory_to_video
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor, cat
|
|
7
|
+
from torch.utils.data import ConcatDataset, Dataset
|
|
8
|
+
|
|
9
|
+
from memmap_replay_buffer.replay_buffer import ReplayBuffer, default, exists, pad_right_at_dim
|
|
10
|
+
|
|
11
|
+
class ConcatReplayBuffer:
|
|
12
|
+
def __init__(self, folders: list[str | Path]):
|
|
13
|
+
if len(folders) == 0:
|
|
14
|
+
raise ValueError('ConcatReplayBuffer requires at least one folder')
|
|
15
|
+
|
|
16
|
+
self.buffers = [ReplayBuffer.from_folder(folder, read_only = True) for folder in folders]
|
|
17
|
+
|
|
18
|
+
first_buffer = self.buffers[0]
|
|
19
|
+
self.fieldnames = first_buffer.fieldnames
|
|
20
|
+
self.meta_fieldnames = first_buffer.meta_fieldnames
|
|
21
|
+
|
|
22
|
+
for buffer in self.buffers[1:]:
|
|
23
|
+
if buffer.fieldnames != self.fieldnames:
|
|
24
|
+
raise ValueError(f'All buffers must have the same fieldnames - got {buffer.fieldnames} and {self.fieldnames}')
|
|
25
|
+
|
|
26
|
+
if buffer.meta_fieldnames != self.meta_fieldnames:
|
|
27
|
+
raise ValueError(f'All buffers must have the same meta_fieldnames - got {buffer.meta_fieldnames} and {self.meta_fieldnames}')
|
|
28
|
+
|
|
29
|
+
self.read_only = True
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def num_episodes(self):
|
|
33
|
+
return sum(buffer.num_episodes for buffer in self.buffers)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def max_episodes(self):
|
|
37
|
+
return sum(buffer.max_episodes for buffer in self.buffers)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def max_timesteps(self):
|
|
41
|
+
return max(buffer.max_timesteps for buffer in self.buffers)
|
|
42
|
+
|
|
43
|
+
def __len__(self):
|
|
44
|
+
return sum(len(buffer) for buffer in self.buffers)
|
|
45
|
+
|
|
46
|
+
def dataset(self, **kwargs) -> Dataset:
|
|
47
|
+
if len(self) == 0:
|
|
48
|
+
raise ValueError('replay buffer is empty')
|
|
49
|
+
|
|
50
|
+
datasets = [buffer.dataset(**kwargs) for buffer in self.buffers if len(buffer) > 0]
|
|
51
|
+
return ConcatDataset(datasets)
|
|
52
|
+
|
|
53
|
+
def dataloader(self, *args, **kwargs):
|
|
54
|
+
return ReplayBuffer.dataloader(self, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
def get_all_data(
|
|
57
|
+
self,
|
|
58
|
+
fields: tuple[str, ...] | None = None,
|
|
59
|
+
meta_fields: tuple[str, ...] | None = None,
|
|
60
|
+
device: torch.device | str | None = None
|
|
61
|
+
) -> dict[str, Tensor]:
|
|
62
|
+
|
|
63
|
+
all_data = [buffer.get_all_data(fields = fields, meta_fields = meta_fields, device = device) for buffer in self.buffers]
|
|
64
|
+
all_data = [data for data in all_data if len(data) > 0]
|
|
65
|
+
|
|
66
|
+
if not all_data:
|
|
67
|
+
return dict()
|
|
68
|
+
|
|
69
|
+
data_fields = self.fieldnames if not exists(fields) and not exists(meta_fields) else default(fields, ())
|
|
70
|
+
|
|
71
|
+
out = dict()
|
|
72
|
+
|
|
73
|
+
for key in all_data[0].keys():
|
|
74
|
+
tensors = [data[key] for data in all_data if key in data]
|
|
75
|
+
|
|
76
|
+
if key in data_fields and tensors[0].ndim > 1:
|
|
77
|
+
# pad trajectories to the longest across buffers
|
|
78
|
+
max_time = max(t.shape[1] for t in tensors)
|
|
79
|
+
tensors = [pad_right_at_dim(t, max_time - t.shape[1], dim = 1) for t in tensors]
|
|
80
|
+
|
|
81
|
+
out[key] = cat(tensors, dim = 0)
|
|
82
|
+
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
def flush(self):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def _read_only(self, *args, **kwargs):
|
|
89
|
+
raise NotImplementedError('ConcatReplayBuffer is read-only')
|
|
90
|
+
|
|
91
|
+
clear = reset_ = advance_episode = store_batch = store_meta_batch = \
|
|
92
|
+
one_episode = batched_episode = store_datapoint = store_meta_datapoint = \
|
|
93
|
+
store_batch_datapoint = store_batch_meta_datapoint = store = store_episode = update = _read_only
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict, namedtuple
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
from numpy import ndarray
|
|
9
|
+
import torch
|
|
10
|
+
from torch import is_tensor
|
|
11
|
+
from torch.utils.data import DataLoader, Dataset, default_collate
|
|
12
|
+
|
|
13
|
+
from memmap_replay_buffer.replay_buffer import tree_map_to_device
|
|
14
|
+
|
|
15
|
+
# helpers
|
|
16
|
+
|
|
17
|
+
def exists(v):
|
|
18
|
+
return v is not None
|
|
19
|
+
|
|
20
|
+
def default(v, d):
|
|
21
|
+
return v if exists(v) else d
|
|
22
|
+
|
|
23
|
+
def is_group_batched(value, num_groups):
|
|
24
|
+
if is_tensor(value) or isinstance(value, ndarray):
|
|
25
|
+
return value.ndim > 0 and value.shape[0] == num_groups
|
|
26
|
+
|
|
27
|
+
if isinstance(value, (list, tuple)):
|
|
28
|
+
return len(value) == num_groups
|
|
29
|
+
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def stack(values, dim = 0):
|
|
33
|
+
if is_tensor(values[0]):
|
|
34
|
+
return torch.stack(values, dim = dim)
|
|
35
|
+
|
|
36
|
+
return np.stack(values, axis = dim)
|
|
37
|
+
|
|
38
|
+
# dataset
|
|
39
|
+
|
|
40
|
+
class TensorDictDataset(Dataset):
|
|
41
|
+
def __init__(self, data: dict[str, torch.Tensor]):
|
|
42
|
+
self.data = data
|
|
43
|
+
self.num_samples = len(next(iter(data.values())))
|
|
44
|
+
|
|
45
|
+
def __len__(self):
|
|
46
|
+
return self.num_samples
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, index):
|
|
49
|
+
return {name: value[index] for name, value in self.data.items()}
|
|
50
|
+
|
|
51
|
+
# class
|
|
52
|
+
|
|
53
|
+
class EpisodeCollector:
|
|
54
|
+
""" accumulates a group-batched rollout step by step, then either stores it or trains on it in memory
|
|
55
|
+
|
|
56
|
+
layouts are explicit, so the time and group axes can never be silently swapped
|
|
57
|
+
|
|
58
|
+
`append` - one step, group-batched (num_groups, *shape) or (num_groups, 1, *shape)
|
|
59
|
+
`extend` - whole trajectories, time-major (time, num_groups, *shape), e.g. returns / advantages
|
|
60
|
+
`env_major` / `time_major` - read back (num_groups, time, ...) / (time, num_groups, ...)
|
|
61
|
+
`all_data` / `dataloader` - env-major tensors / flattened minibatches, entirely in memory
|
|
62
|
+
`store` - write one episode per group, then `clear`
|
|
63
|
+
`clear` - discard the current rollout
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@beartype
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
replay_buffer: Any, # ReplayBuffer | ReplayBufferH5PY, without a hard dependency on h5py
|
|
70
|
+
num_groups: int,
|
|
71
|
+
fieldnames: tuple[str, ...] | None = None,
|
|
72
|
+
meta_fieldnames: tuple[str, ...] | None = None,
|
|
73
|
+
strict: bool = True
|
|
74
|
+
):
|
|
75
|
+
self.replay_buffer = replay_buffer
|
|
76
|
+
self.num_groups = num_groups
|
|
77
|
+
|
|
78
|
+
self.fieldnames = set(default(fieldnames, tuple(replay_buffer.fieldnames)))
|
|
79
|
+
self.meta_fieldnames = set(default(meta_fieldnames, tuple(replay_buffer.meta_fieldnames)))
|
|
80
|
+
|
|
81
|
+
self.shapes = replay_buffer.shapes
|
|
82
|
+
self.meta_shapes = replay_buffer.meta_shapes
|
|
83
|
+
self.strict = strict
|
|
84
|
+
|
|
85
|
+
self.clear()
|
|
86
|
+
|
|
87
|
+
def __len__(self):
|
|
88
|
+
return self.step_count
|
|
89
|
+
|
|
90
|
+
def clear(self):
|
|
91
|
+
self.episode_data = [defaultdict(list) for _ in range(self.num_groups)]
|
|
92
|
+
self.step_count = 0
|
|
93
|
+
|
|
94
|
+
# fields
|
|
95
|
+
|
|
96
|
+
def field_kind(self, name):
|
|
97
|
+
if name in self.fieldnames:
|
|
98
|
+
return 'data'
|
|
99
|
+
|
|
100
|
+
if name in self.meta_fieldnames:
|
|
101
|
+
return 'meta'
|
|
102
|
+
|
|
103
|
+
if self.strict:
|
|
104
|
+
raise KeyError(f'unknown field {name} - valid fields are {sorted(self.fieldnames | self.meta_fieldnames)}')
|
|
105
|
+
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def field_shape(self, name):
|
|
109
|
+
return self.meta_shapes[name] if name in self.meta_fieldnames else self.shapes[name]
|
|
110
|
+
|
|
111
|
+
def per_group(self, name, value):
|
|
112
|
+
# (num_groups, *shape) / (num_groups, 1, *shape) -> num_groups values of shape (*shape)
|
|
113
|
+
|
|
114
|
+
if isinstance(value, (list, tuple)):
|
|
115
|
+
return list(value)
|
|
116
|
+
|
|
117
|
+
shape = tuple(self.field_shape(name))
|
|
118
|
+
rest = tuple(value.shape[1:])
|
|
119
|
+
|
|
120
|
+
assert rest in (shape, (1, *shape)), f'field {name} - per-group shape {rest} should be {shape} or (1, *shape)'
|
|
121
|
+
|
|
122
|
+
if rest != shape:
|
|
123
|
+
value = value[:, 0]
|
|
124
|
+
|
|
125
|
+
return list(value)
|
|
126
|
+
|
|
127
|
+
# adding data
|
|
128
|
+
|
|
129
|
+
@beartype
|
|
130
|
+
def append(self, **data):
|
|
131
|
+
# one step, group-batched values
|
|
132
|
+
|
|
133
|
+
for name, value in data.items():
|
|
134
|
+
kind = self.field_kind(name)
|
|
135
|
+
|
|
136
|
+
if not exists(kind):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if kind == 'meta':
|
|
140
|
+
values = [value] * self.num_groups
|
|
141
|
+
else:
|
|
142
|
+
shape = tuple(self.field_shape(name))
|
|
143
|
+
|
|
144
|
+
assert is_group_batched(value, self.num_groups), (
|
|
145
|
+
f'field {name} - `append` expects per-step group-batched values of shape ({self.num_groups}, *{shape}) '
|
|
146
|
+
f'or ({self.num_groups}, 1, *{shape}) - for whole trajectories in time-major (time, {self.num_groups}, *{shape}), use `extend`'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
values = self.per_group(name, value)
|
|
150
|
+
|
|
151
|
+
for group_index, group_value in enumerate(values):
|
|
152
|
+
self.episode_data[group_index][name].append(group_value)
|
|
153
|
+
|
|
154
|
+
self.step_count += 1
|
|
155
|
+
|
|
156
|
+
@beartype
|
|
157
|
+
def extend(self, **trajectories):
|
|
158
|
+
# whole trajectories, time-major (time, num_groups, *shape)
|
|
159
|
+
|
|
160
|
+
for name, value in trajectories.items():
|
|
161
|
+
kind = self.field_kind(name)
|
|
162
|
+
|
|
163
|
+
if not exists(kind):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
assert is_tensor(value) or isinstance(value, ndarray), f'field {name} - `extend` expects a tensor / ndarray of shape (time, {self.num_groups}, *shape)'
|
|
167
|
+
|
|
168
|
+
time = value.shape[0]
|
|
169
|
+
|
|
170
|
+
assert value.ndim > 1 and value.shape[1] == self.num_groups, (
|
|
171
|
+
f'field {name} - `extend` expects time-major (time, {self.num_groups}, *shape), got {tuple(value.shape)}'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if len(self) == 0:
|
|
175
|
+
self.step_count = time
|
|
176
|
+
else:
|
|
177
|
+
assert time == len(self), f'field {name} - time dim {time} does not match the {len(self)} collected steps'
|
|
178
|
+
|
|
179
|
+
shape = tuple(self.field_shape(name))
|
|
180
|
+
assert tuple(value.shape[2:]) == shape, f'field {name} - feature shape {tuple(value.shape[2:])} does not match {shape}'
|
|
181
|
+
|
|
182
|
+
for group_index in range(self.num_groups):
|
|
183
|
+
self.episode_data[group_index][name].extend(list(value[:, group_index]))
|
|
184
|
+
|
|
185
|
+
# reading back
|
|
186
|
+
|
|
187
|
+
def groups(self, name):
|
|
188
|
+
# one value per group, i.e. a list of num_groups tensors / arrays
|
|
189
|
+
|
|
190
|
+
if self.field_kind(name) == 'meta':
|
|
191
|
+
return [data[name][-1] for data in self.episode_data]
|
|
192
|
+
|
|
193
|
+
return [stack(data[name], dim = 0) for data in self.episode_data]
|
|
194
|
+
|
|
195
|
+
def time_major(self, name):
|
|
196
|
+
# (time, num_groups, *shape) for data fields, (num_groups,) for meta fields
|
|
197
|
+
|
|
198
|
+
return stack(self.groups(name), dim = 1 if self.field_kind(name) == 'data' else 0)
|
|
199
|
+
|
|
200
|
+
def env_major(self, name):
|
|
201
|
+
# (num_groups, time, *shape), the layout generalized advantage estimation expects
|
|
202
|
+
|
|
203
|
+
values = self.time_major(name)
|
|
204
|
+
|
|
205
|
+
if self.field_kind(name) == 'meta':
|
|
206
|
+
return values
|
|
207
|
+
|
|
208
|
+
return values.swapaxes(0, 1)
|
|
209
|
+
|
|
210
|
+
# in-memory training - no storing required
|
|
211
|
+
|
|
212
|
+
def all_data(self, fields = None, device: torch.device | str | None = None):
|
|
213
|
+
# dict of env-major tensors, (num_groups, time, *shape), for the collected data fields
|
|
214
|
+
|
|
215
|
+
if len(self) == 0:
|
|
216
|
+
raise ValueError('collector is empty')
|
|
217
|
+
|
|
218
|
+
collected = tuple(name for name in self.episode_data[0] if name in self.fieldnames)
|
|
219
|
+
|
|
220
|
+
data = {name: self.env_major(name) for name in default(fields, collected)}
|
|
221
|
+
return tree_map_to_device(data, device)
|
|
222
|
+
|
|
223
|
+
def dataloader(
|
|
224
|
+
self,
|
|
225
|
+
batch_size,
|
|
226
|
+
fields = None,
|
|
227
|
+
filter_fields = None,
|
|
228
|
+
to_named_tuple = None,
|
|
229
|
+
shuffle = False,
|
|
230
|
+
device: torch.device | str | None = None,
|
|
231
|
+
**kwargs
|
|
232
|
+
):
|
|
233
|
+
# minibatches flattened over (num_groups, time) for the current rollout, entirely in memory
|
|
234
|
+
|
|
235
|
+
data = {name: torch.as_tensor(value).reshape(-1, *value.shape[2:]) for name, value in self.all_data().items()}
|
|
236
|
+
|
|
237
|
+
if exists(filter_fields) and len(filter_fields) > 0:
|
|
238
|
+
mask = None
|
|
239
|
+
for name, filter_value in filter_fields.items():
|
|
240
|
+
field_mask = data[name] == filter_value
|
|
241
|
+
mask = field_mask if not exists(mask) else (mask & field_mask)
|
|
242
|
+
|
|
243
|
+
data = {name: value[mask] for name, value in data.items()}
|
|
244
|
+
|
|
245
|
+
fields = default(fields, tuple(data.keys()))
|
|
246
|
+
data = {name: data[name] for name in fields}
|
|
247
|
+
|
|
248
|
+
dataset = TensorDictDataset(data)
|
|
249
|
+
|
|
250
|
+
NamedTupleCls = None
|
|
251
|
+
if exists(to_named_tuple):
|
|
252
|
+
sanitized_fields = tuple(f.lstrip('_') if f.startswith('_') else f for f in to_named_tuple)
|
|
253
|
+
NamedTupleCls = namedtuple('Batch', sanitized_fields)
|
|
254
|
+
|
|
255
|
+
def collate_fn(data):
|
|
256
|
+
batch = default_collate(data)
|
|
257
|
+
|
|
258
|
+
if exists(NamedTupleCls):
|
|
259
|
+
for field in to_named_tuple:
|
|
260
|
+
if field not in batch:
|
|
261
|
+
raise ValueError(f'field `{field}` not found in batch. available fields: {list(batch.keys())}')
|
|
262
|
+
|
|
263
|
+
batch = NamedTupleCls(**{san: batch[orig] for orig, san in zip(to_named_tuple, sanitized_fields)})
|
|
264
|
+
|
|
265
|
+
return tree_map_to_device(batch, device)
|
|
266
|
+
|
|
267
|
+
return DataLoader(dataset, batch_size = batch_size, shuffle = shuffle, collate_fn = collate_fn, **kwargs)
|
|
268
|
+
|
|
269
|
+
# storing
|
|
270
|
+
|
|
271
|
+
@beartype
|
|
272
|
+
def store(self, **meta_data):
|
|
273
|
+
for group_index, group_data in enumerate(self.episode_data):
|
|
274
|
+
store_kwargs = {name: stack(values, dim = 0) if name in self.fieldnames else values[-1] for name, values in group_data.items()}
|
|
275
|
+
|
|
276
|
+
for name, value in meta_data.items():
|
|
277
|
+
if name not in self.meta_fieldnames:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
store_kwargs[name] = value[group_index] if is_group_batched(value, self.num_groups) else value
|
|
281
|
+
|
|
282
|
+
self.replay_buffer.store_episode(**store_kwargs)
|
|
283
|
+
|
|
284
|
+
self.clear()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>inspect-replay-buffer</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: grid;
|
|
14
|
+
place-items: center;
|
|
15
|
+
background: #0f1012;
|
|
16
|
+
color: #e8e8e8;
|
|
17
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
18
|
+
}
|
|
19
|
+
video {
|
|
20
|
+
max-width: min(75vw, 1200px);
|
|
21
|
+
max-height: 88vh;
|
|
22
|
+
border-radius: 6px;
|
|
23
|
+
background: #000;
|
|
24
|
+
}
|
|
25
|
+
aside {
|
|
26
|
+
position: fixed;
|
|
27
|
+
top: 50%;
|
|
28
|
+
right: 20px;
|
|
29
|
+
transform: translateY(-50%);
|
|
30
|
+
width: 240px;
|
|
31
|
+
max-height: 88vh;
|
|
32
|
+
overflow: auto;
|
|
33
|
+
padding: 16px;
|
|
34
|
+
background: #17181b;
|
|
35
|
+
border: 1px solid #26282c;
|
|
36
|
+
border-radius: 6px;
|
|
37
|
+
}
|
|
38
|
+
aside h1 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #7fb4e6; }
|
|
39
|
+
aside h2 { margin: 16px 0 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a8f98; }
|
|
40
|
+
dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
|
|
41
|
+
dt { color: #8a8f98; }
|
|
42
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
43
|
+
#extras:empty { display: none; }
|
|
44
|
+
@media (max-width: 1000px) {
|
|
45
|
+
body { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px; }
|
|
46
|
+
video { max-width: 100%; }
|
|
47
|
+
aside { position: static; transform: none; width: 100%; max-width: 480px; }
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<video id="replay" src="/video" controls autoplay loop muted></video>
|
|
53
|
+
<aside>
|
|
54
|
+
<h1>replay buffer</h1>
|
|
55
|
+
<dl id="stats"></dl>
|
|
56
|
+
<h2>time</h2>
|
|
57
|
+
<dl>
|
|
58
|
+
<dt>frame</dt><dd id="frame">0</dd>
|
|
59
|
+
</dl>
|
|
60
|
+
<div id="extras"></div>
|
|
61
|
+
</aside>
|
|
62
|
+
<script>
|
|
63
|
+
const stats = __REPLAY_STATS__;
|
|
64
|
+
const video = document.getElementById('replay');
|
|
65
|
+
const extras = document.getElementById('extras');
|
|
66
|
+
|
|
67
|
+
const statsList = document.getElementById('stats');
|
|
68
|
+
for (const [key, value] of Object.entries(stats)) {
|
|
69
|
+
const dt = document.createElement('dt');
|
|
70
|
+
dt.textContent = key;
|
|
71
|
+
const dd = document.createElement('dd');
|
|
72
|
+
dd.textContent = value;
|
|
73
|
+
statsList.append(dt, dd);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const frameLabel = document.getElementById('frame');
|
|
77
|
+
const updateTime = () => {
|
|
78
|
+
frameLabel.textContent = `${Math.round(video.currentTime * stats.fps)} / ${stats.frames}`;
|
|
79
|
+
};
|
|
80
|
+
video.addEventListener('timeupdate', updateTime);
|
|
81
|
+
updateTime();
|
|
82
|
+
|
|
83
|
+
// future: sync value predictions / actions / other per-timestep elements to `video.currentTime`
|
|
84
|
+
window.replay = { stats, video, extras };
|
|
85
|
+
</script>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import webbrowser
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
# helpers
|
|
11
|
+
|
|
12
|
+
def render_page(stats: dict):
|
|
13
|
+
template_path = Path(__file__).parent / 'frontend' / 'index.html'
|
|
14
|
+
return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(stats))
|
|
15
|
+
|
|
16
|
+
def parse_range(range_header: str | None, size: int):
|
|
17
|
+
if not range_header or not range_header.startswith('bytes='):
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
start_str, _, end_str = range_header[len('bytes='):].split(',')[0].partition('-')
|
|
21
|
+
|
|
22
|
+
if not start_str:
|
|
23
|
+
# suffix range, e.g. bytes=-500
|
|
24
|
+
return max(size - int(end_str), 0), size - 1
|
|
25
|
+
|
|
26
|
+
return int(start_str), min(int(end_str), size - 1) if end_str else size - 1
|
|
27
|
+
|
|
28
|
+
# server
|
|
29
|
+
|
|
30
|
+
class ReplayBufferHandler(BaseHTTPRequestHandler):
|
|
31
|
+
video_path: Path
|
|
32
|
+
page: str
|
|
33
|
+
|
|
34
|
+
protocol_version = 'HTTP/1.1'
|
|
35
|
+
|
|
36
|
+
def log_message(self, *args):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def do_GET(self):
|
|
40
|
+
path = urlparse(self.path).path
|
|
41
|
+
|
|
42
|
+
if path == '/':
|
|
43
|
+
self.send_page()
|
|
44
|
+
elif path == '/video':
|
|
45
|
+
self.send_video()
|
|
46
|
+
else:
|
|
47
|
+
self.send_error(404)
|
|
48
|
+
|
|
49
|
+
def send_page(self):
|
|
50
|
+
body = self.page.encode()
|
|
51
|
+
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
54
|
+
self.send_header('Content-Length', str(len(body)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def send_video(self):
|
|
59
|
+
size = self.video_path.stat().st_size
|
|
60
|
+
byte_range = parse_range(self.headers.get('Range'), size)
|
|
61
|
+
|
|
62
|
+
start, end = byte_range if byte_range else (0, size - 1)
|
|
63
|
+
is_partial = byte_range is not None
|
|
64
|
+
|
|
65
|
+
self.send_response(206 if is_partial else 200)
|
|
66
|
+
self.send_header('Content-Type', 'video/mp4')
|
|
67
|
+
self.send_header('Content-Length', str(end - start + 1))
|
|
68
|
+
self.send_header('Accept-Ranges', 'bytes')
|
|
69
|
+
|
|
70
|
+
if is_partial:
|
|
71
|
+
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
|
72
|
+
|
|
73
|
+
self.end_headers()
|
|
74
|
+
|
|
75
|
+
# stream the selected slice, seeking works via repeated range requests
|
|
76
|
+
|
|
77
|
+
with open(self.video_path, 'rb') as f:
|
|
78
|
+
f.seek(start)
|
|
79
|
+
remaining = end - start + 1
|
|
80
|
+
|
|
81
|
+
while remaining > 0:
|
|
82
|
+
chunk = f.read(min(remaining, 1 << 20))
|
|
83
|
+
|
|
84
|
+
if not chunk:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
self.wfile.write(chunk)
|
|
88
|
+
remaining -= len(chunk)
|
|
89
|
+
|
|
90
|
+
def make_server(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000):
|
|
91
|
+
handler = type('Handler', (ReplayBufferHandler,), dict(
|
|
92
|
+
video_path = Path(video_path),
|
|
93
|
+
page = render_page(stats)
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
return ThreadingHTTPServer((host, port), handler)
|
|
97
|
+
|
|
98
|
+
def serve(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000, open_browser = True, block = True):
|
|
99
|
+
httpd = make_server(video_path, stats, host, port)
|
|
100
|
+
url = f'http://{host}:{httpd.server_address[1]}/'
|
|
101
|
+
|
|
102
|
+
print(f'serving replay at {url} (ctrl-c to stop)')
|
|
103
|
+
|
|
104
|
+
if open_browser:
|
|
105
|
+
threading.Thread(target = webbrowser.open, args = (url,), daemon = True).start()
|
|
106
|
+
|
|
107
|
+
if not block:
|
|
108
|
+
threading.Thread(target = httpd.serve_forever, daemon = True).start()
|
|
109
|
+
return httpd, url
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
httpd.serve_forever()
|
|
113
|
+
except KeyboardInterrupt:
|
|
114
|
+
pass
|
|
115
|
+
finally:
|
|
116
|
+
httpd.server_close()
|
|
117
|
+
|
|
118
|
+
return httpd, url
|