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 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]]