rl-mind 0.1.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.
- rl_mind/__init__.py +55 -0
- rl_mind/collectors.py +195 -0
- rl_mind/core.py +152 -0
- rl_mind/data.py +179 -0
- rl_mind/env.py +282 -0
- rl_mind/envs.py +76 -0
- rl_mind/evaluation.py +167 -0
- rl_mind/mazemdp_env.py +182 -0
- rl_mind/nn.py +44 -0
- rl_mind/notebook.py +118 -0
- rl_mind/py.typed +0 -0
- rl_mind-0.1.0.dist-info/METADATA +313 -0
- rl_mind-0.1.0.dist-info/RECORD +15 -0
- rl_mind-0.1.0.dist-info/WHEEL +5 -0
- rl_mind-0.1.0.dist-info/top_level.txt +1 -0
rl_mind/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Typed, minimal RL infrastructure for the Master MIND RL practicals.
|
|
2
|
+
|
|
3
|
+
Published on PyPI as ``rl-mind``: the notebooks import it like any other
|
|
4
|
+
package. See ``rl_mind.md`` (the package README) for the documentation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rl_mind.collectors import (
|
|
8
|
+
EpisodeCollector,
|
|
9
|
+
RolloutCollector,
|
|
10
|
+
TransitionCollector,
|
|
11
|
+
)
|
|
12
|
+
from rl_mind.core import Action, ActionT, Actor, StochasticAction, TensorStruct
|
|
13
|
+
from rl_mind.data import Episode, ReplayBuffer, Rollout, Transitions, minibatches
|
|
14
|
+
|
|
15
|
+
# NOTE: rl_mind.envs is deliberately NOT imported here: importing it registers
|
|
16
|
+
# the extra gymnasium environments, and importing the package must have no
|
|
17
|
+
# side effect. Use an explicit `import rl_mind.envs` where needed.
|
|
18
|
+
from rl_mind.env import EnvStep, VecEnv
|
|
19
|
+
from rl_mind.evaluation import EvalResult, Evaluator, record_video
|
|
20
|
+
from rl_mind.nn import build_mlp, soft_update
|
|
21
|
+
from rl_mind.notebook import (
|
|
22
|
+
is_notebook,
|
|
23
|
+
outputs_directory,
|
|
24
|
+
run_directory,
|
|
25
|
+
setup_tensorboard,
|
|
26
|
+
video_display,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Action",
|
|
31
|
+
"ActionT",
|
|
32
|
+
"Actor",
|
|
33
|
+
"EnvStep",
|
|
34
|
+
"EpisodeCollector",
|
|
35
|
+
"EvalResult",
|
|
36
|
+
"Evaluator",
|
|
37
|
+
"Episode",
|
|
38
|
+
"ReplayBuffer",
|
|
39
|
+
"Rollout",
|
|
40
|
+
"RolloutCollector",
|
|
41
|
+
"StochasticAction",
|
|
42
|
+
"TensorStruct",
|
|
43
|
+
"Transitions",
|
|
44
|
+
"TransitionCollector",
|
|
45
|
+
"VecEnv",
|
|
46
|
+
"build_mlp",
|
|
47
|
+
"is_notebook",
|
|
48
|
+
"minibatches",
|
|
49
|
+
"outputs_directory",
|
|
50
|
+
"record_video",
|
|
51
|
+
"run_directory",
|
|
52
|
+
"setup_tensorboard",
|
|
53
|
+
"soft_update",
|
|
54
|
+
"video_display",
|
|
55
|
+
]
|
rl_mind/collectors.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Collecting interaction data.
|
|
2
|
+
|
|
3
|
+
The collectors run an actor in a vectorized environment and package the
|
|
4
|
+
result into the containers of `rl_mind.data`. They deal with the
|
|
5
|
+
auto-reset bookkeeping so that the learning code never has to.
|
|
6
|
+
|
|
7
|
+
- `TransitionCollector.collect(n_steps)` performs `n_steps` steps in
|
|
8
|
+
*every* environment and returns the corresponding batch of
|
|
9
|
+
`Transitions` (for replay-buffer based algorithms). The environments
|
|
10
|
+
keep their state between calls.
|
|
11
|
+
- `EpisodeCollector.collect(n_episodes)` resets the environments and
|
|
12
|
+
returns at least `n_episodes` complete `Episode`s (for episodic
|
|
13
|
+
algorithms, which need *on-policy* full episodes).
|
|
14
|
+
- `RolloutCollector.collect(n_steps)` returns a `[T, B]` `Rollout` (for
|
|
15
|
+
on-policy algorithms such as A2C and PPO). It requires an environment
|
|
16
|
+
with `same_step_reset=True`, so that every row of the rollout is a
|
|
17
|
+
valid transition. The environments keep their state between calls.
|
|
18
|
+
|
|
19
|
+
All count the total number of recorded environment steps in `steps`.
|
|
20
|
+
Actions are computed with `torch.no_grad()`: if the learning algorithm
|
|
21
|
+
needs gradients (e.g. of action log-probabilities), it must recompute them
|
|
22
|
+
from the stored observations and actions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from rl_mind.core import ActionT, Actor, TensorStruct
|
|
26
|
+
from rl_mind.data import Episode, Rollout, Transitions
|
|
27
|
+
from rl_mind.env import EnvStep, VecEnv
|
|
28
|
+
|
|
29
|
+
from typing import Generic
|
|
30
|
+
|
|
31
|
+
import torch
|
|
32
|
+
|
|
33
|
+
__all__ = ["TransitionCollector", "EpisodeCollector", "RolloutCollector"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stack(items):
|
|
37
|
+
"""Stack a list of observations (plain tensors, or `TensorStruct`s for
|
|
38
|
+
`Dict` observation spaces) along a new leading dimension."""
|
|
39
|
+
first = items[0]
|
|
40
|
+
if isinstance(first, TensorStruct):
|
|
41
|
+
return type(first).stack(items)
|
|
42
|
+
return torch.stack(items)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TransitionCollector(Generic[ActionT]):
|
|
46
|
+
"""Collect batches of transitions by running an actor in a `VecEnv`"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, env: VecEnv, actor: Actor[ActionT]):
|
|
49
|
+
self.env = env
|
|
50
|
+
self.actor = actor
|
|
51
|
+
#: Total number of recorded transitions
|
|
52
|
+
self.steps = 0
|
|
53
|
+
self._obs: torch.Tensor | None = None
|
|
54
|
+
self._just_reset = torch.zeros(env.num_envs, dtype=torch.bool)
|
|
55
|
+
|
|
56
|
+
def collect(self, n_steps: int) -> Transitions[ActionT]:
|
|
57
|
+
"""Perform `n_steps` steps in every environment
|
|
58
|
+
|
|
59
|
+
Returns the recorded transitions (at most `n_steps * num_envs`;
|
|
60
|
+
steps where an environment was auto-resetting are skipped).
|
|
61
|
+
"""
|
|
62
|
+
if self._obs is None:
|
|
63
|
+
self._obs = self.env.reset()
|
|
64
|
+
|
|
65
|
+
chunks: list[Transitions[ActionT]] = []
|
|
66
|
+
with torch.no_grad():
|
|
67
|
+
for _ in range(n_steps):
|
|
68
|
+
action = self.actor(self._obs)
|
|
69
|
+
step = self.env.step(action.value)
|
|
70
|
+
|
|
71
|
+
# Do not record the transitions of environments that were
|
|
72
|
+
# auto-resetting during this step (their action was ignored)
|
|
73
|
+
valid = ~self._just_reset
|
|
74
|
+
chunks.append(
|
|
75
|
+
Transitions(
|
|
76
|
+
obs=self._obs,
|
|
77
|
+
action=action,
|
|
78
|
+
reward=step.reward,
|
|
79
|
+
next_obs=step.next_obs,
|
|
80
|
+
terminated=step.terminated,
|
|
81
|
+
)[valid]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._obs = step.obs
|
|
85
|
+
if not self.env.same_step_reset:
|
|
86
|
+
# With next-step reset, the step following `done` is the
|
|
87
|
+
# (invalid) auto-reset step
|
|
88
|
+
self._just_reset = step.done
|
|
89
|
+
|
|
90
|
+
transitions = Transitions.cat(chunks)
|
|
91
|
+
self.steps += len(transitions)
|
|
92
|
+
return transitions
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EpisodeCollector(Generic[ActionT]):
|
|
96
|
+
"""Collect full episodes by running an actor in a `VecEnv`"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, env: VecEnv, actor: Actor[ActionT]):
|
|
99
|
+
self.env = env
|
|
100
|
+
self.actor = actor
|
|
101
|
+
#: Total number of recorded steps
|
|
102
|
+
self.steps = 0
|
|
103
|
+
|
|
104
|
+
def collect(self, n_episodes: int) -> list[Episode[ActionT]]:
|
|
105
|
+
"""Collect at least `n_episodes` complete episodes
|
|
106
|
+
|
|
107
|
+
The environments are reset at the beginning of the call, so that all
|
|
108
|
+
the returned episodes are collected with the *current* actor.
|
|
109
|
+
"""
|
|
110
|
+
num_envs = self.env.num_envs
|
|
111
|
+
obs = self.env.reset()
|
|
112
|
+
just_reset = torch.zeros(num_envs, dtype=torch.bool)
|
|
113
|
+
|
|
114
|
+
# Per-environment lists of observations, actions and rewards
|
|
115
|
+
obs_lists: list[list[torch.Tensor]] = [[] for _ in range(num_envs)]
|
|
116
|
+
action_lists: list[list[ActionT]] = [[] for _ in range(num_envs)]
|
|
117
|
+
reward_lists: list[list[torch.Tensor]] = [[] for _ in range(num_envs)]
|
|
118
|
+
|
|
119
|
+
episodes: list[Episode[ActionT]] = []
|
|
120
|
+
with torch.no_grad():
|
|
121
|
+
while len(episodes) < n_episodes:
|
|
122
|
+
action = self.actor(obs)
|
|
123
|
+
step = self.env.step(action.value)
|
|
124
|
+
|
|
125
|
+
for i in range(num_envs):
|
|
126
|
+
if just_reset[i]:
|
|
127
|
+
# This environment was auto-resetting: nothing to record
|
|
128
|
+
continue
|
|
129
|
+
obs_lists[i].append(obs[i])
|
|
130
|
+
action_lists[i].append(action[i])
|
|
131
|
+
reward_lists[i].append(step.reward[i])
|
|
132
|
+
self.steps += 1
|
|
133
|
+
|
|
134
|
+
if step.done[i]:
|
|
135
|
+
episodes.append(
|
|
136
|
+
Episode(
|
|
137
|
+
obs=_stack(obs_lists[i]),
|
|
138
|
+
action=type(action).stack(action_lists[i]),
|
|
139
|
+
reward=torch.stack(reward_lists[i]),
|
|
140
|
+
final_obs=step.next_obs[i],
|
|
141
|
+
terminated=bool(step.terminated[i]),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
obs_lists[i], action_lists[i], reward_lists[i] = [], [], []
|
|
145
|
+
|
|
146
|
+
obs = step.obs
|
|
147
|
+
if not self.env.same_step_reset:
|
|
148
|
+
just_reset = step.done
|
|
149
|
+
|
|
150
|
+
return episodes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RolloutCollector(Generic[ActionT]):
|
|
154
|
+
"""Collect fixed-length `[T, B]` rollouts by running an actor in a `VecEnv`"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, env: VecEnv, actor: Actor[ActionT]):
|
|
157
|
+
assert env.same_step_reset, (
|
|
158
|
+
"RolloutCollector requires a VecEnv created with "
|
|
159
|
+
"same_step_reset=True (so that every rollout row is a valid "
|
|
160
|
+
"transition)"
|
|
161
|
+
)
|
|
162
|
+
self.env = env
|
|
163
|
+
self.actor = actor
|
|
164
|
+
#: Total number of recorded steps
|
|
165
|
+
self.steps = 0
|
|
166
|
+
self._obs: torch.Tensor | None = None
|
|
167
|
+
|
|
168
|
+
def collect(self, n_steps: int) -> Rollout[ActionT]:
|
|
169
|
+
"""Perform `n_steps` steps in every environment; returns the
|
|
170
|
+
corresponding `[n_steps, num_envs]` rollout"""
|
|
171
|
+
if self._obs is None:
|
|
172
|
+
self._obs = self.env.reset()
|
|
173
|
+
|
|
174
|
+
obs_list: list[torch.Tensor] = []
|
|
175
|
+
action_list: list[ActionT] = []
|
|
176
|
+
steps: list[EnvStep] = []
|
|
177
|
+
with torch.no_grad():
|
|
178
|
+
for _ in range(n_steps):
|
|
179
|
+
action = self.actor(self._obs)
|
|
180
|
+
step = self.env.step(action.value)
|
|
181
|
+
|
|
182
|
+
obs_list.append(self._obs)
|
|
183
|
+
action_list.append(action)
|
|
184
|
+
steps.append(step)
|
|
185
|
+
self._obs = step.obs
|
|
186
|
+
|
|
187
|
+
self.steps += n_steps * self.env.num_envs
|
|
188
|
+
return Rollout(
|
|
189
|
+
obs=_stack(obs_list),
|
|
190
|
+
action=type(action_list[0]).stack(action_list),
|
|
191
|
+
reward=torch.stack([step.reward for step in steps]),
|
|
192
|
+
next_obs=_stack([step.next_obs for step in steps]),
|
|
193
|
+
terminated=torch.stack([step.terminated for step in steps]),
|
|
194
|
+
truncated=torch.stack([step.truncated for step in steps]),
|
|
195
|
+
)
|
rl_mind/core.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Actions, actors and tensor containers.
|
|
2
|
+
|
|
3
|
+
All the data exchanged between the agent and the environment is stored in
|
|
4
|
+
**typed containers**: frozen dataclasses whose fields are ``torch.Tensor``s
|
|
5
|
+
(`TensorStruct`). They can be indexed (``batch[idx]``), concatenated and
|
|
6
|
+
stacked like tensors, but each field keeps its name and its type.
|
|
7
|
+
|
|
8
|
+
- An `Action` is what an `Actor` returns: at minimum the action tensor itself
|
|
9
|
+
(``value``). Subclasses can carry more information, e.g. a
|
|
10
|
+
`StochasticAction` also stores the log-probability of the sampled action.
|
|
11
|
+
- An `Actor` is a ``torch.nn.Module`` mapping a batch of observations to an
|
|
12
|
+
`Action`. Calling ``actor(obs)`` gives the *training-time* behavior
|
|
13
|
+
(sampling, exploration noise); ``actor.act(obs)`` gives the *evaluation*
|
|
14
|
+
action (deterministic).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import dataclass, fields, replace
|
|
19
|
+
from typing import Callable, Generic, Self, Sequence, TypeVar
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
import torch.nn as nn
|
|
23
|
+
from torch import Tensor
|
|
24
|
+
|
|
25
|
+
__all__ = ["TensorStruct", "Action", "StochasticAction", "Actor", "ActionT"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class TensorStruct:
|
|
30
|
+
"""A frozen dataclass of tensors (possibly nested).
|
|
31
|
+
|
|
32
|
+
Provides tensor-like operations applied field by field: indexing,
|
|
33
|
+
concatenation, stacking. All tensor fields must share the same first
|
|
34
|
+
(batch) dimension.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def _map(self, fn: Callable[[Tensor], Tensor]) -> Self:
|
|
38
|
+
"""Return a copy where `fn` has been applied to every tensor field"""
|
|
39
|
+
values = {}
|
|
40
|
+
for field in fields(self):
|
|
41
|
+
value = getattr(self, field.name)
|
|
42
|
+
if isinstance(value, TensorStruct):
|
|
43
|
+
values[field.name] = value._map(fn)
|
|
44
|
+
elif isinstance(value, Tensor):
|
|
45
|
+
values[field.name] = fn(value)
|
|
46
|
+
return replace(self, **values)
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, index) -> Self:
|
|
49
|
+
"""Index or slice all the tensor fields along the batch dimension
|
|
50
|
+
|
|
51
|
+
`index` can be anything a tensor accepts: an integer, a slice, an
|
|
52
|
+
index tensor or a boolean mask.
|
|
53
|
+
"""
|
|
54
|
+
return self._map(lambda tensor: tensor[index])
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
"""The size of the batch dimension"""
|
|
58
|
+
for field in fields(self):
|
|
59
|
+
value = getattr(self, field.name)
|
|
60
|
+
if isinstance(value, TensorStruct):
|
|
61
|
+
return len(value)
|
|
62
|
+
if isinstance(value, Tensor):
|
|
63
|
+
return value.shape[0]
|
|
64
|
+
raise ValueError(f"{type(self).__name__} has no tensor field")
|
|
65
|
+
|
|
66
|
+
def set_(self, index, value: Self) -> None:
|
|
67
|
+
"""Write `value` at position(s) `index` (in-place)"""
|
|
68
|
+
for field in fields(self):
|
|
69
|
+
target = getattr(self, field.name)
|
|
70
|
+
if isinstance(target, TensorStruct):
|
|
71
|
+
target.set_(index, getattr(value, field.name))
|
|
72
|
+
elif isinstance(target, Tensor):
|
|
73
|
+
target[index] = getattr(value, field.name)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def cat(cls, items: Sequence[Self]) -> Self:
|
|
77
|
+
"""Concatenate a sequence of structures along the batch dimension"""
|
|
78
|
+
values = {}
|
|
79
|
+
for field in fields(items[0]):
|
|
80
|
+
value = getattr(items[0], field.name)
|
|
81
|
+
if isinstance(value, TensorStruct):
|
|
82
|
+
values[field.name] = type(value).cat(
|
|
83
|
+
[getattr(item, field.name) for item in items]
|
|
84
|
+
)
|
|
85
|
+
elif isinstance(value, Tensor):
|
|
86
|
+
values[field.name] = torch.cat(
|
|
87
|
+
[getattr(item, field.name) for item in items]
|
|
88
|
+
)
|
|
89
|
+
return replace(items[0], **values)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def stack(cls, items: Sequence[Self]) -> Self:
|
|
93
|
+
"""Stack a sequence of structures along a new (first) dimension"""
|
|
94
|
+
values = {}
|
|
95
|
+
for field in fields(items[0]):
|
|
96
|
+
value = getattr(items[0], field.name)
|
|
97
|
+
if isinstance(value, TensorStruct):
|
|
98
|
+
values[field.name] = type(value).stack(
|
|
99
|
+
[getattr(item, field.name) for item in items]
|
|
100
|
+
)
|
|
101
|
+
elif isinstance(value, Tensor):
|
|
102
|
+
values[field.name] = torch.stack(
|
|
103
|
+
[getattr(item, field.name) for item in items]
|
|
104
|
+
)
|
|
105
|
+
return replace(items[0], **values)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Action(TensorStruct):
|
|
110
|
+
"""What an actor returns: a batch of actions
|
|
111
|
+
|
|
112
|
+
:param value: The action tensor, shape `[B, action_dim]` (continuous
|
|
113
|
+
actions) or `[B]` (discrete actions)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
value: Tensor
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class StochasticAction(Action):
|
|
121
|
+
"""An action sampled from a distribution
|
|
122
|
+
|
|
123
|
+
:param log_prob: The log-probability of the sampled action, shape `[B]`
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
log_prob: Tensor
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
#: Type of the action returned by an actor
|
|
130
|
+
ActionT = TypeVar("ActionT", bound=Action)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Actor(nn.Module, ABC, Generic[ActionT]):
|
|
134
|
+
"""Maps a batch of observations to actions
|
|
135
|
+
|
|
136
|
+
An actor is a `torch.nn.Module`; subclasses implement `forward` (the
|
|
137
|
+
training-time behavior, e.g. sampling) and can override `act` (the
|
|
138
|
+
evaluation-time behavior, deterministic by default).
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def forward(self, obs: Tensor) -> ActionT:
|
|
143
|
+
"""Compute the action for a batch of observations (shape `[B, obs_dim]`)"""
|
|
144
|
+
...
|
|
145
|
+
|
|
146
|
+
def act(self, obs: Tensor) -> Tensor:
|
|
147
|
+
"""Deterministic action used for evaluation (defaults to `forward().value`)"""
|
|
148
|
+
return self(obs).value
|
|
149
|
+
|
|
150
|
+
# Overriden so that type checkers know the return type of `actor(obs)`
|
|
151
|
+
def __call__(self, obs: Tensor) -> ActionT:
|
|
152
|
+
return super().__call__(obs)
|
rl_mind/data.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Transitions, episodes, rollouts and the replay buffer.
|
|
2
|
+
|
|
3
|
+
Two ways of storing interaction data, depending on the algorithm:
|
|
4
|
+
|
|
5
|
+
- `Transitions` is a flat batch of $(s, a, r, s', \textrm{terminated})$
|
|
6
|
+
tuples, used by *off-policy* algorithms (DQN, DDPG, TD3, SAC) through a
|
|
7
|
+
`ReplayBuffer`;
|
|
8
|
+
- `Episode` is one full episode with time-indexed tensors, used by
|
|
9
|
+
*episodic* algorithms (REINFORCE);
|
|
10
|
+
- `Rollout` is a fixed-length `[T, B]` segment of interaction, used by
|
|
11
|
+
*on-policy* algorithms (A2C, PPO), with `minibatches` to iterate over
|
|
12
|
+
its (flattened) steps.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from rl_mind.core import ActionT, TensorStruct
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Generic, Iterator, TypeVar
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from torch import Tensor
|
|
22
|
+
|
|
23
|
+
__all__ = ["Transitions", "ReplayBuffer", "Episode", "Rollout", "minibatches"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Transitions(TensorStruct, Generic[ActionT]):
|
|
28
|
+
"""A batch of `N` transitions $(s, a, r, s', \\textrm{terminated})$
|
|
29
|
+
|
|
30
|
+
:param obs: The observations $s$, shape `[N, obs_dim]`
|
|
31
|
+
:param action: The actions $a$ (an `Action`, tensors of shape `[N, ...]`)
|
|
32
|
+
:param reward: The rewards $r$, shape `[N]`
|
|
33
|
+
:param next_obs: The next observations $s'$, shape `[N, obs_dim]`
|
|
34
|
+
:param terminated: Whether $s'$ is a terminal state, shape `[N]` (bool).
|
|
35
|
+
When the episode was *truncated* (time limit), this is `False`: the
|
|
36
|
+
value of $s'$ must be bootstrapped.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
obs: Tensor
|
|
40
|
+
action: ActionT
|
|
41
|
+
reward: Tensor
|
|
42
|
+
next_obs: Tensor
|
|
43
|
+
terminated: Tensor
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ReplayBuffer(Generic[ActionT]):
|
|
47
|
+
"""A fixed-capacity buffer of transitions (a "ring buffer": when full,
|
|
48
|
+
new transitions overwrite the oldest ones)"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, capacity: int):
|
|
51
|
+
self.capacity = int(capacity)
|
|
52
|
+
self._storage: Transitions[ActionT] | None = None
|
|
53
|
+
self._size = 0
|
|
54
|
+
self._pos = 0
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
"""The number of transitions currently stored"""
|
|
58
|
+
return self._size
|
|
59
|
+
|
|
60
|
+
def add(self, transitions: Transitions[ActionT]) -> None:
|
|
61
|
+
"""Add a batch of transitions to the buffer"""
|
|
62
|
+
if self._storage is None:
|
|
63
|
+
# Allocate the storage on first use, using the shapes/dtypes of
|
|
64
|
+
# the incoming transitions
|
|
65
|
+
self._storage = transitions._map(
|
|
66
|
+
lambda tensor: torch.empty(
|
|
67
|
+
(self.capacity, *tensor.shape[1:]), dtype=tensor.dtype
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
indices = (self._pos + torch.arange(len(transitions))) % self.capacity
|
|
72
|
+
self._storage.set_(indices, transitions)
|
|
73
|
+
self._pos = int((self._pos + len(transitions)) % self.capacity)
|
|
74
|
+
self._size = min(self._size + len(transitions), self.capacity)
|
|
75
|
+
|
|
76
|
+
def sample(self, batch_size: int) -> Transitions[ActionT]:
|
|
77
|
+
"""Sample a batch of transitions uniformly at random (with replacement)"""
|
|
78
|
+
assert self._storage is not None, "The replay buffer is empty"
|
|
79
|
+
indices = torch.randint(0, self._size, (batch_size,))
|
|
80
|
+
return self._storage[indices]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Episode(Generic[ActionT]):
|
|
85
|
+
"""One full episode of length `T`
|
|
86
|
+
|
|
87
|
+
:param obs: The observations $s_0, \\ldots, s_{T-1}$, shape `[T, obs_dim]`
|
|
88
|
+
:param action: The actions $a_0, \\ldots, a_{T-1}$ (tensors `[T, ...]`)
|
|
89
|
+
:param reward: The rewards $r_1, \\ldots, r_T$ where $r_{t+1}$ is
|
|
90
|
+
received after performing $a_t$ in $s_t$; shape `[T]`
|
|
91
|
+
:param final_obs: The last observation $s_T$, shape `[obs_dim]` (see also
|
|
92
|
+
the `all_obs` property, which appends it to `obs`)
|
|
93
|
+
:param terminated: True if $s_T$ is a terminal state; False if the
|
|
94
|
+
episode was truncated (e.g. time limit), in which case the value of
|
|
95
|
+
$s_T$ must be bootstrapped.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
obs: Tensor
|
|
99
|
+
action: ActionT
|
|
100
|
+
reward: Tensor
|
|
101
|
+
final_obs: Tensor
|
|
102
|
+
terminated: bool
|
|
103
|
+
|
|
104
|
+
def __len__(self) -> int:
|
|
105
|
+
"""The number of steps in the episode"""
|
|
106
|
+
return self.reward.shape[0]
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def all_obs(self):
|
|
110
|
+
"""The whole observation sequence $s_0, \\ldots, s_T$, shape `[T+1, ...]`
|
|
111
|
+
|
|
112
|
+
`obs` only holds the `T` observations on which an action was taken;
|
|
113
|
+
`all_obs` appends the final one. Hence `all_obs[t+1]` is the successor
|
|
114
|
+
of `all_obs[t]`: `all_obs[1:]` are the successors of `obs`, and
|
|
115
|
+
`critic(all_obs)` gives the `T+1` values $V(s_0), \\ldots, V(s_T)$.
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(self.obs, TensorStruct):
|
|
118
|
+
# Structured (`Dict`) observations: `final_obs` has no time
|
|
119
|
+
# dimension, add it before concatenating
|
|
120
|
+
final_obs = self.final_obs._map(lambda tensor: tensor.unsqueeze(0))
|
|
121
|
+
return type(self.obs).cat([self.obs, final_obs])
|
|
122
|
+
return torch.cat([self.obs, self.final_obs.unsqueeze(0)])
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def cumulated_reward(self) -> float:
|
|
126
|
+
"""The (undiscounted) sum of rewards of the episode"""
|
|
127
|
+
return float(self.reward.sum())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Rollout(TensorStruct, Generic[ActionT]):
|
|
132
|
+
"""A fixed-length segment of interaction with `B` parallel environments
|
|
133
|
+
|
|
134
|
+
All the tensors are time-indexed: shape `[T, B, ...]`. A rollout may
|
|
135
|
+
span several episodes: episode boundaries are marked by `terminated` /
|
|
136
|
+
`truncated`, and `next_obs[t]` is always the true successor state of
|
|
137
|
+
`obs[t]` (the *final* observation when the episode ends at step `t`,
|
|
138
|
+
while `obs[t+1]` is then the first observation of the next episode).
|
|
139
|
+
|
|
140
|
+
:param obs: The observations $s_t$, shape `[T, B, obs_dim]`
|
|
141
|
+
:param action: The actions $a_t$ (an `Action`, tensors `[T, B, ...]`)
|
|
142
|
+
:param reward: The rewards $r_{t+1}$, shape `[T, B]`
|
|
143
|
+
:param next_obs: The successor states $s_{t+1}$, shape `[T, B, obs_dim]`
|
|
144
|
+
:param terminated: Whether $s_{t+1}$ is a terminal state, shape `[T, B]`
|
|
145
|
+
:param truncated: Whether the episode was truncated at this step, shape
|
|
146
|
+
`[T, B]`
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
obs: Tensor
|
|
150
|
+
action: ActionT
|
|
151
|
+
reward: Tensor
|
|
152
|
+
next_obs: Tensor
|
|
153
|
+
terminated: Tensor
|
|
154
|
+
truncated: Tensor
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def done(self) -> Tensor:
|
|
158
|
+
"""True at episode boundaries (terminated or truncated), shape `[T, B]`"""
|
|
159
|
+
return self.terminated | self.truncated
|
|
160
|
+
|
|
161
|
+
def flatten(self) -> "Rollout[ActionT]":
|
|
162
|
+
"""Merge the time and environment dimensions: `[T, B, ...] -> [T*B, ...]`"""
|
|
163
|
+
return self._map(lambda tensor: tensor.flatten(0, 1))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
StructT = TypeVar("StructT", bound=TensorStruct)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def minibatches(
|
|
170
|
+
data: StructT, batch_size: int, shuffle: bool = True
|
|
171
|
+
) -> Iterator[StructT]:
|
|
172
|
+
"""Iterate over minibatches of a batch of data
|
|
173
|
+
|
|
174
|
+
The data is partitioned: each sample belongs to exactly one minibatch
|
|
175
|
+
(the last one may be smaller). With `shuffle`, the partition is random.
|
|
176
|
+
"""
|
|
177
|
+
indices = torch.randperm(len(data)) if shuffle else torch.arange(len(data))
|
|
178
|
+
for start in range(0, len(data), batch_size):
|
|
179
|
+
yield data[indices[start : start + batch_size]]
|