import numpy as np
from typing import Tuple, Optional, List
[docs]
class IRISReplayBuffer:
"""Replay buffer for IRIS (Imagination with auto-Regression over an Inner Speech) training.
Stores (observation, action, reward, terminal) tuples in a ring buffer
and supports sampling contiguous sequences for world model training.
Features:
- Ring buffer with fixed capacity (FIFO eviction when full)
- Stores uint8 images for memory efficiency
- Samples sequences with validation to avoid episode boundaries
- Supports sequence sampling for temporal learning
Memory Layout:
- observations: (capacity, C, H, W) uint8
- actions: (capacity, action_size) float32
- rewards: (capacity,) float32
- terminals: (capacity,) float32
Args:
size (int): Maximum number of transitions to store.
obs_shape (tuple): Shape of observations as (C, H, W).
action_size (int): Dimension of actions.
seq_len (int): Length of sequences to sample (default: 20).
batch_size (int): Number of sequences per batch (default: 64).
Attributes:
size (int): Buffer capacity.
obs_shape (tuple): Observation shape.
action_size (int): Action dimension.
seq_len (int): Sequence length.
batch_size (int): Batch size.
steps (int): Total transitions added.
episodes (int): Number of episode terminations observed.
"""
def __init__(
self,
size: int,
obs_shape: Tuple[int, int, int],
action_size: int,
seq_len: int = 20,
batch_size: int = 64,
):
self.size = size
self.obs_shape = obs_shape # (C, H, W)
self.action_size = action_size
self.seq_len = seq_len
self.batch_size = batch_size
self.idx = 0
self.full = False
self.steps = 0
self.episodes = 0
self.observations = np.zeros((size, *obs_shape), dtype=np.uint8)
self.actions = np.zeros((size, action_size), dtype=np.float32)
self.rewards = np.zeros((size,), dtype=np.float32)
self.terminals = np.zeros((size,), dtype=np.float32)
[docs]
def add(
self, obs: np.ndarray, action: np.ndarray, reward: float, terminal: bool
) -> None:
"""Add a transition to the buffer.
Args:
obs: Observation array with shape (C, H, W).
action: Action array with shape (action_size,).
reward: Scalar reward value.
terminal: Boolean indicating if episode terminated.
"""
self.observations[self.idx] = obs
self.actions[self.idx] = action
self.rewards[self.idx] = reward
self.terminals[self.idx] = float(terminal)
self.idx = (self.idx + 1) % self.size
self.full = self.full or self.idx == 0
self.steps += 1
self.episodes += 1 if terminal else 0
[docs]
def sample_sequence(
self, seq_len: Optional[int] = None
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Sample a batch of sequences for world model training.
Returns:
observations: (batch_size, seq_len+1, C, H, W)
actions: (batch_size, seq_len, action_size)
rewards: (batch_size, seq_len)
terminals: (batch_size, seq_len)
"""
if seq_len is None:
seq_len = self.seq_len
batch_size = self.batch_size
L = seq_len
# Sample starting indices
idxs = self._sample_idxs(L, batch_size)
# Build sequences
observations = []
actions = []
rewards = []
terminals = []
for idx in idxs:
# Get sequence of observations (L+1 for predicting next frame)
obs_seq = self.observations[idx : idx + L + 1]
act_seq = self.actions[idx : idx + L]
rew_seq = self.rewards[idx : idx + L]
term_seq = self.terminals[idx : idx + L]
# Handle wrapping
if len(obs_seq) < L + 1:
# Pad by wrapping around
obs_seq = np.concatenate(
[obs_seq, self.observations[: L + 1 - len(obs_seq)]]
)
act_seq = np.concatenate([act_seq, self.actions[: L - len(act_seq)]])
rew_seq = np.concatenate([rew_seq, self.rewards[: L - len(rew_seq)]])
term_seq = np.concatenate(
[term_seq, self.terminals[: L - len(term_seq)]]
)
observations.append(obs_seq)
actions.append(act_seq)
rewards.append(rew_seq)
terminals.append(term_seq)
return (
np.stack(observations),
np.stack(actions),
np.stack(rewards),
np.stack(terminals),
)
def _sample_idxs(self, L: int, n: int) -> np.ndarray:
"""Sample n starting indices whose sequences stay inside one episode.
A window that straddles a terminal splices the end of one episode onto
the start of the next; the transformer would then be trained to predict
a reset frame from the previous episode's final frames.
The valid starts are enumerated once and drawn from, rather than sampled
and patched up: the previous version resampled a bad index exactly once
and never rechecked, so a replacement that also straddled a terminal was
used anyway.
"""
# A sequence reads observations[idx : idx + L + 1], so the last valid
# start for a partially-filled buffer is idx - L - 1; using idx - L let
# the final sequence read one uninitialised (all-zero) frame.
valid_start_range = self.size if self.full else self.idx - L - 1
if valid_start_range <= 0:
return np.zeros(n, dtype=int)
candidates = np.arange(valid_start_range)
if L > 1:
# terminals[idx + L - 1] is allowed: it is the window's last
# transition, and its successor frame is the episode's true final
# observation -- a target the model should learn.
offsets = np.arange(L - 1)
window = (candidates[:, None] + offsets[None, :]) % self.size
candidates = candidates[~(self.terminals[window] > 0).any(axis=1)]
if candidates.size == 0:
# Every window crosses a terminal (very short episodes relative to
# L). Fall back to unfiltered starts rather than failing outright.
candidates = np.arange(valid_start_range)
return np.random.choice(candidates, size=n)
[docs]
def sample_with_burn_in(
self, batch_size: int, burn_in: int
) -> Tuple[np.ndarray, np.ndarray]:
"""Sample start frames together with the frames that precede them.
IRIS burns in the previous frames to initialise the actor-critic's LSTM
state before imagining from a given frame (paper A.3). This returns both
halves so the caller does not have to reason about buffer indexing.
Episode boundaries are respected: any burn-in frame at or before a
terminal is replaced by a repeat of the oldest valid frame, so context
never bleeds across episodes.
Args:
batch_size: Number of start frames to draw.
burn_in: Number of preceding frames to return per start frame.
Returns:
start_obs: (batch_size, C, H, W) uint8 frames to imagine from.
burn_in_obs: (batch_size, burn_in, C, H, W) uint8 preceding frames.
Empty along axis 1 when ``burn_in`` is 0.
"""
n = len(self)
if n == 0:
raise ValueError("Cannot sample from an empty replay buffer.")
# Start far enough in that a full burn-in window exists where possible.
# Once the ring buffer has wrapped, also skip starts whose window would
# straddle the write head: positions just below ``idx`` hold the newest
# frames while positions just above hold the oldest, so such a window
# would splice together two distant points in time.
low = min(burn_in, max(n - 1, 0))
# Declared unparameterised: the three branches below produce arrays the
# numpy stubs give different shape types to, and only the values matter.
starts: np.ndarray
if low >= n:
starts = np.full(batch_size, n - 1, dtype=int)
elif self.full and burn_in > 0:
candidates: np.ndarray = np.arange(low, n)
straddles = (candidates - burn_in < self.idx) & (candidates >= self.idx)
candidates = candidates[~straddles]
if candidates.size == 0:
candidates = np.arange(low, n)
starts = np.random.choice(candidates, size=batch_size)
else:
starts = np.random.randint(low, n, size=batch_size)
start_obs = self.observations[starts]
if burn_in <= 0:
empty = np.zeros((batch_size, 0, *self.obs_shape), dtype=np.uint8)
return start_obs, empty
burn_in_obs = np.zeros((batch_size, burn_in, *self.obs_shape), dtype=np.uint8)
for i, start in enumerate(starts):
first = max(0, start - burn_in)
window = self.observations[first:start]
# Truncate at the most recent terminal inside the window so the
# burn-in stays within one episode.
terms = self.terminals[first:start]
terminal_positions = np.nonzero(terms > 0)[0]
if terminal_positions.size > 0:
window = window[terminal_positions[-1] + 1 :]
if window.shape[0] == 0:
# No valid history: repeat the start frame itself.
window = start_obs[i][None]
# Left-pad by repeating the oldest valid frame.
pad = burn_in - window.shape[0]
if pad > 0:
window = np.concatenate([np.repeat(window[:1], pad, axis=0), window])
burn_in_obs[i] = window
return start_obs, burn_in_obs
[docs]
def sample_single(self) -> Tuple[np.ndarray, np.ndarray, float, float]:
"""Sample a single transition for online updates."""
idx = np.random.randint(0, self.size if self.full else self.idx)
return (
self.observations[idx],
self.actions[idx],
self.rewards[idx],
self.terminals[idx],
)
def __len__(self) -> int:
return self.size if self.full else self.idx
@property
def buffer_capacity(self) -> int:
"""Returns the total capacity of the buffer."""
return self.size
[docs]
class IRISOnPolicyBuffer:
"""On-policy buffer for collecting trajectories during environment interaction.
Used to store the current episode data before adding to the main replay buffer.
Unlike the main replay buffer, this collects trajectories in a list-based
structure that's cleared after each episode.
Useful for:
- Collecting complete episode trajectories
- Storing data before batch processing
- Temporary storage during environment interaction
Args:
max_steps (int): Maximum number of steps to store (default: 1000).
Attributes:
max_steps (int): Maximum buffer capacity.
observations (list): List of observations.
actions (list): List of actions.
rewards (list): List of rewards.
terminals (list): List of terminal flags.
"""
def __init__(self, max_steps: int = 1000):
self.max_steps = max_steps
# Typed lists to satisfy static type checkers
self.observations: List[np.ndarray] = []
self.actions: List[np.ndarray] = []
self.rewards: List[float] = []
self.terminals: List[float] = []
[docs]
def add(
self, obs: np.ndarray, action: np.ndarray, reward: float, terminal: bool
) -> None:
self.observations.append(obs)
self.actions.append(action)
self.rewards.append(reward)
self.terminals.append(float(terminal))
[docs]
def clear(self) -> None:
self.observations = []
self.actions = []
self.rewards = []
self.terminals = []
[docs]
def get_arrays(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
return (
np.array(self.observations),
np.array(self.actions),
np.array(self.rewards),
np.array(self.terminals),
)
def __len__(self) -> int:
return len(self.observations)