| """Experience replay buffer for RL agents.""" |
|
|
| from __future__ import annotations |
|
|
| from collections import deque |
| from dataclasses import dataclass |
| from typing import Optional, Tuple |
|
|
| import numpy as np |
| import torch |
|
|
|
|
| @dataclass |
| class Experience: |
| """Single experience tuple for replay buffer.""" |
| state: torch.Tensor |
| action: int |
| reward: float |
| next_state: torch.Tensor |
| done: bool |
|
|
|
|
| class ReplayBuffer: |
| """Experience replay buffer for off-policy RL algorithms. |
| |
| Stores and samples experience tuples (s, a, r, s', done) for training. |
| Supports prioritized experience replay with TD-error-based priorities. |
| """ |
|
|
| def __init__(self, capacity: int = 10000, prioritized: bool = True, alpha: float = 0.6): |
| """Initialize replay buffer. |
| |
| Args: |
| capacity: Maximum number of experiences to store |
| prioritized: Whether to use prioritized experience replay |
| alpha: Priority exponent (0 = uniform, 1 = full prioritization) |
| """ |
| self.capacity = capacity |
| self.prioritized = prioritized |
| self.alpha = alpha |
| self.buffer: deque[Experience] = deque(maxlen=capacity) |
| self.priorities: deque[float] = deque(maxlen=capacity) |
| self.position = 0 |
|
|
| def push(self, experience: Experience, priority: Optional[float] = None) -> None: |
| """Add experience to buffer. |
| |
| Args: |
| experience: Experience tuple to store |
| priority: Priority for prioritized replay (default: max priority) |
| """ |
| self.buffer.append(experience) |
|
|
| if self.prioritized: |
| if priority is None: |
| |
| priority = max(self.priorities) if self.priorities else 1.0 |
| self.priorities.append(priority) |
|
|
| def sample(self, batch_size: int, beta: float = 0.4) -> Tuple[torch.Tensor, ...]: |
| """Sample a batch of experiences. |
| |
| Args: |
| batch_size: Number of experiences to sample |
| beta: Importance sampling exponent (0 = no correction, 1 = full correction) |
| |
| Returns: |
| Tuple of (states, actions, rewards, next_states, dones, weights, indices) |
| """ |
| if len(self.buffer) < batch_size: |
| raise ValueError(f"Buffer contains {len(self.buffer)} experiences, need {batch_size}") |
|
|
| if self.prioritized: |
| |
| priorities = np.array(self.priorities) |
| probs = priorities ** self.alpha |
| probs /= probs.sum() |
|
|
| indices = np.random.choice(len(self.buffer), batch_size, p=probs, replace=False) |
|
|
| |
| weights = (len(self.buffer) * probs[indices]) ** (-beta) |
| weights /= weights.max() |
| weights = torch.FloatTensor(weights) |
| else: |
| indices = np.random.choice(len(self.buffer), batch_size, replace=False) |
| weights = torch.ones(batch_size) |
|
|
| experiences = [self.buffer[idx] for idx in indices] |
|
|
| states = torch.stack([e.state for e in experiences]) |
| |
| |
| |
| if isinstance(experiences[0].action, torch.Tensor): |
| actions = torch.stack([e.action for e in experiences]) |
| else: |
| try: |
| actions = torch.FloatTensor([e.action for e in experiences]) |
| except (TypeError, ValueError): |
| actions = torch.stack([torch.tensor(e.action) for e in experiences]) |
| |
| rewards = torch.FloatTensor([e.reward for e in experiences]) |
| next_states = torch.stack([e.next_state for e in experiences]) |
| dones = torch.FloatTensor([e.done for e in experiences]) |
|
|
| return states, actions, rewards, next_states, dones, weights, indices |
|
|
| def update_priorities(self, indices: np.ndarray, priorities: np.ndarray) -> None: |
| """Update priorities for sampled experiences. |
| |
| Args: |
| indices: Indices of sampled experiences |
| priorities: New priority values (typically TD errors) |
| """ |
| if not self.prioritized: |
| return |
|
|
| for idx, priority in zip(indices, priorities): |
| if 0 <= idx < len(self.priorities): |
| self.priorities[idx] = priority |
|
|
| def __len__(self) -> int: |
| return len(self.buffer) |
|
|
| def is_ready(self, batch_size: int) -> bool: |
| """Check if buffer has enough experiences for sampling.""" |
| return len(self.buffer) >= batch_size |
|
|