|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -from jaxtyping import Array |
| 3 | +import dataclasses |
| 4 | +from functools import partial |
4 | 5 |
|
5 | | -from oryx.spaces import AbstractSpace |
| 6 | +import equinox as eqx |
| 7 | +import jax |
| 8 | +from jax import lax |
| 9 | +from jax import numpy as jnp |
| 10 | +from jax import random as jr |
| 11 | +from jaxtyping import Array, ArrayLike, Bool, Float, Key, PyTree |
6 | 12 |
|
7 | 13 | from .base_buffer import AbstractBuffer |
8 | 14 |
|
9 | 15 |
|
10 | | -class RolloutBuffer(AbstractBuffer, strict=True): |
| 16 | +class RolloutBuffer[ActType, ObsType](AbstractBuffer, strict=True): |
| 17 | + """ |
| 18 | + RolloutBuffer used by on-policy algorithms. |
11 | 19 |
|
12 | | - observation_space: AbstractSpace |
13 | | - observations: Array |
14 | | - actions: Array |
15 | | - rewards: Array |
16 | | - advantages: Array |
17 | | - returns: Array |
18 | | - episode_starts: Array |
19 | | - log_probs: Array |
20 | | - values: Array |
| 20 | + Designed for scans and JIT compilation. |
21 | 21 |
|
22 | | - def reset(self) -> RolloutBuffer: |
23 | | - pass |
| 22 | + Example: |
| 23 | + ```python |
| 24 | + from jax import lax |
24 | 25 |
|
| 26 | + def step(carry, x) -> RolloutBuffer: |
| 27 | + # Do stuff with the carry and x |
| 28 | + return carry, RolloutBuffer( |
| 29 | + # Variable assignments here |
| 30 | + ) |
| 31 | +
|
| 32 | + rollout_buffer = lax.scan(step, init, xs) # Collect a filled buffer |
| 33 | + rollout_buffer = rollout_buffer.compute_returns_and_advantages( |
| 34 | + carry.value, |
| 35 | + carry.done, |
| 36 | + 1.0, |
| 37 | + 0.99, |
| 38 | + ) |
| 39 | + ``` |
| 40 | + """ |
| 41 | + |
| 42 | + observations: PyTree[ObsType] |
| 43 | + actions: PyTree[ActType] |
| 44 | + rewards: Float[Array, " *size"] |
| 45 | + terminations: Bool[Array, " *size"] |
| 46 | + truncations: Bool[Array, " *size"] |
| 47 | + log_probs: Float[Array, " *size"] |
| 48 | + values: Float[Array, " *size"] |
| 49 | + returns: Float[Array, " *size"] |
| 50 | + advantages: Float[Array, " *size"] |
| 51 | + states: PyTree[eqx.nn.State] |
| 52 | + |
| 53 | + def __init__( |
| 54 | + self, |
| 55 | + observations: PyTree[ObsType], |
| 56 | + actions: PyTree[ActType], |
| 57 | + rewards: Float[ArrayLike, " *size"], |
| 58 | + terminations: Bool[ArrayLike, " *size"], |
| 59 | + truncations: Bool[ArrayLike, " *size"], |
| 60 | + log_probs: Float[ArrayLike, " *size"], |
| 61 | + values: Float[ArrayLike, " *size"], |
| 62 | + states: PyTree[eqx.nn.State], |
| 63 | + returns: Float[ArrayLike, " *size"] | None = None, |
| 64 | + advantages: Float[ArrayLike, " *size"] | None = None, |
| 65 | + ): |
| 66 | + """ |
| 67 | + Initialize the RolloutBuffer with the given parameters. |
| 68 | +
|
| 69 | + Returns and advantages can be provided, but if not, they will be filled with |
| 70 | + NaNs. |
| 71 | + """ |
| 72 | + # TODO: Add type checks for observations, actions, etc |
| 73 | + # TODO: Add shape checks for rewards, terminations, truncations, log_probs, |
| 74 | + # values |
| 75 | + |
| 76 | + self.observations = observations |
| 77 | + self.actions = actions |
| 78 | + self.rewards = jnp.asarray(rewards) |
| 79 | + self.terminations = jnp.asarray(terminations) |
| 80 | + self.truncations = jnp.asarray(truncations) |
| 81 | + self.log_probs = jnp.asarray(log_probs) |
| 82 | + self.values = jnp.asarray(values) |
| 83 | + self.states = states |
| 84 | + self.returns = ( |
| 85 | + jnp.asarray(returns) if returns else jnp.full_like(values, jnp.nan) |
| 86 | + ) |
| 87 | + self.advantages = ( |
| 88 | + jnp.asarray(advantages) if advantages else jnp.full_like(values, jnp.nan) |
| 89 | + ) |
| 90 | + |
| 91 | + @eqx.filter_jit |
25 | 92 | def compute_returns_and_advantages( |
26 | | - self, last_value: Array, dones: Array |
27 | | - ) -> RolloutBuffer: |
28 | | - pass |
| 93 | + self, |
| 94 | + last_value: Float[ArrayLike, ""], |
| 95 | + done: Bool[ArrayLike, ""], |
| 96 | + gae_lambda: Float[ArrayLike, ""], |
| 97 | + gamma: Float[ArrayLike, ""], |
| 98 | + ) -> RolloutBuffer[ActType, ObsType]: |
| 99 | + """ |
| 100 | + Compute returns and advantages for the rollout buffer using Generalized |
| 101 | + Advantage Estimation. |
| 102 | +
|
| 103 | + Works under JIT compilation. |
| 104 | +
|
| 105 | + :param last_value: The estimated value of the last state. |
| 106 | + :param done: Whether the last state is terminal. |
| 107 | + :param gae_lambda: The lambda parameter for GAE. |
| 108 | + :param gamma: The discount factor for future rewards. |
| 109 | + """ |
| 110 | + last_value = jnp.asarray(last_value) |
| 111 | + done = jnp.asarray(done) |
| 112 | + gamma = jnp.asarray(gamma) |
| 113 | + gae_lambda = jnp.asarray(gae_lambda) |
| 114 | + |
| 115 | + dones = jnp.logical_or(self.terminations, self.truncations) |
| 116 | + |
| 117 | + next_values = jnp.concatenate( |
| 118 | + [self.values[1:], jnp.array([last_value])], axis=0 |
| 119 | + ) |
| 120 | + |
| 121 | + next_non_terminal = jnp.concatenate( |
| 122 | + [1.0 - dones, jnp.array([1.0 - done])], axis=0 |
| 123 | + ) |
| 124 | + |
| 125 | + deltas = self.rewards + gamma * next_values * next_non_terminal - self.values |
| 126 | + |
| 127 | + def scan_fn( |
| 128 | + advantage_carry: Float[Array, ""], |
| 129 | + x: tuple[Float[Array, ""], Float[Array, ""]], |
| 130 | + ) -> tuple[Float[Array, ""], Float[Array, ""]]: |
| 131 | + delta, next_non_terminal = x |
| 132 | + advantage = delta + gamma * gae_lambda * next_non_terminal * advantage_carry |
| 133 | + return advantage, advantage |
| 134 | + |
| 135 | + _, advantages = lax.scan( |
| 136 | + scan_fn, jnp.array(0.0), (jnp.flip(deltas), jnp.flip(next_non_terminal)) |
| 137 | + ) |
| 138 | + advantages = jnp.flip(advantages) |
| 139 | + returns = advantages + self.values |
| 140 | + |
| 141 | + return dataclasses.replace(self, advantages=advantages, returns=returns) |
| 142 | + |
| 143 | + def batches( |
| 144 | + self, batch_size: int, *, key: Key | None = None |
| 145 | + ) -> RolloutBuffer[ActType, ObsType]: |
| 146 | + """ |
| 147 | + Return rollout buffer with batches of the given size. |
| 148 | +
|
| 149 | + The buffer is shuffled if a key is provided. |
| 150 | +
|
| 151 | + This method reshapes the buffer into batches of the specified size. It is not |
| 152 | + the same as a list of butches, but rather a single buffer where each batch is a |
| 153 | + slice of the original data. |
| 154 | + """ |
| 155 | + # TODO: Add warning if batch_size does not divide the number of samples |
| 156 | + if key is None: |
| 157 | + indices = jnp.arange(self.shape[0]) |
| 158 | + else: |
| 159 | + indices = jr.permutation(key, self.shape[0]) |
| 160 | + indices = indices.reshape(-1, batch_size) |
| 161 | + |
| 162 | + return jax.tree.map(partial(jnp.take, indices=indices, axis=0), self) |
| 163 | + |
| 164 | + @property |
| 165 | + def shape(self) -> tuple[int, ...]: |
| 166 | + return self.rewards.shape |
0 commit comments