← Back to list

Efficient Experience Replay with a Prioritized Replay Buffer in DQN

Deep Q-Learning Networks (DQN) have revolutionized reinforcement learning by enabling agents to learn complex policies for environments…

Gábor Veláncsics · 2024-12-16 09:17 · 0 claps · 8.7 min read
#dqn #reinforcement-learning #artificial-intelligence #ddqn #prioritized-replay-buffer
Open on Medium ↗
Wiki topics: AGT · AI Agents ML · Machine Learning AI · AI · General EDU · Education & Learning

Efficient Experience Replay with a Prioritized Replay Buffer in DQN

Deep Q-Learning Networks (DQN) have revolutionized reinforcement learning by enabling agents to learn complex policies for environments with high-dimensional state spaces. A critical innovation in DQN is the replay buffer, which stores the agent’s experiences and replays them during training to break correlations in the data and stabilize learning.

This article presents a Python implementation of a prioritized replay buffer, which enhances the standard replay buffer by sampling experiences based on their temporal-difference (TD) errors. By prioritizing more significant experiences, the agent learns faster and more efficiently.

Why Prioritized Replay Buffers?

The traditional replay buffer samples experiences uniformly, which treats all transitions equally. However, in practice, some transitions are more informative than others. For example, transitions with higher TD errors indicate that the agent’s predictions were less accurate, making these transitions valuable for learning.

The prioritized replay buffer addresses this by assigning a priority to each transition. Experiences with higher priorities are more likely to be sampled, improving learning efficiency and convergence speed.

Implementation Details

The implementation includes two main components:

  1. SumTree: A binary tree data structure used to efficiently store and sample priorities.
  2. ReplayBuffer: The primary replay buffer class that stores transitions and integrates with the SumTree for prioritized sampling.

1. SumTree: Efficient Priority Management

The SumTree class is a binary tree where each node stores the sum of its children. This allows efficient priority sampling by treating the total priority as the root and traversing the tree based on random splits.

import numpy as np

class SumTree:
    def __init__(self, capacity):
        self.capacity = capacity  # Maximum number of elements to store
        self.tree = np.zeros(2 * capacity - 1, dtype=np.float32)  # Binary tree structure
        self.data_pointer = 0

    def add(self, priority):
        # Add priority to the tree
        tree_idx = self.data_pointer + self.capacity - 1
        self.update(tree_idx, priority)
        self.data_pointer += 1
        if self.data_pointer >= self.capacity:
            self.data_pointer = 0

    def update(self, tree_idx, priority):
        # Update a specific node and propagate the change
        change = priority - self.tree[tree_idx]
        self.tree[tree_idx] = priority
        parent = (tree_idx - 1) // 2
        while parent >= 0:
            self.tree[parent] += change
            if parent == 0:
                break
            parent = (parent - 1) // 2

    def get_leaf(self, v):
        parent_idx = 0
        while True:
            left_child = 2 * parent_idx + 1
            right_child = left_child + 1
            if left_child >= len(self.tree):
                leaf_idx = parent_idx
                break
            else:
                if v <= self.tree[left_child]:
                    parent_idx = left_child
                else:
                    v -= self.tree[left_child]
                    parent_idx = right_child
        data_idx = leaf_idx - self.capacity + 1
        return leaf_idx, self.tree[leaf_idx], data_idx

    def total_priority(self):
        return self.tree[0]

Constructor (__init__)

Purpose: Initializes the SumTree with a specified capacity and prepares the data structure.

def __init__(self, capacity):
    self.capacity = capacity
    self.tree = np.zeros(2 * capacity - 1, dtype=np.float32)  # Tree array to store priorities
    self.data_pointer = 0  # Points to the next position for adding data
  • **capacity**: Maximum number of data points the tree can manage.
  • **tree*: A binary tree array where leaf nodes hold priorities, and parent nodes store the sum of their children. Its size is `2 capacity - 1`.
  • **data_pointer**: Tracks the position where the next priority will be inserted.

Add (add)

Purpose: Adds a priority to the SumTree and updates the tree.

def add(self, priority):
    tree_idx = self.data_pointer + self.capacity - 1  # Map data index to tree index
    self.update(tree_idx, priority)  # Update the tree with the new priority
    self.data_pointer = (self.data_pointer + 1) % self.capacity  # Cycle pointer
  • **priority**: The value to be added to the tree.
  • Maps the data_pointer to the corresponding leaf index (tree_idx).
  • Uses cyclic overwriting: When the pointer reaches the capacity, it resets to 0.

Update (update)

Purpose: Updates a specific leaf’s priority and propagates the change up the tree.

def update(self, tree_idx, priority):
    change = priority - self.tree[tree_idx]  # Compute the change in priority
    self.tree[tree_idx] = priority  # Update the leaf
    while tree_idx != 0:  # Propagate the change up the tree
        tree_idx = (tree_idx - 1) // 2  # Move to the parent node
        self.tree[tree_idx] += change
  • **tree_idx**: The index of the leaf node in the tree array.
  • **priority**: The new priority value.
  • Updates the target leaf and adjusts all parent nodes to maintain the correct sum.

Example:

  • If a leaf’s priority increases by +3, all parent nodes above it are incremented by +3.

Get Leaf (get_leaf)

Purpose: Traverses the tree to find a leaf corresponding to a value v.

def get_leaf(self, v):
    parent_idx = 0  # Start at the root
    while True:
        left_child = 2 * parent_idx + 1
        right_child = left_child + 1
        if left_child >= len(self.tree):  # Reached a leaf node
            leaf_idx = parent_idx
            break
        else:
            if v <= self.tree[left_child]:
                parent_idx = left_child
            else:
                v -= self.tree[left_child]
                parent_idx = right_child
    data_idx = leaf_idx - self.capacity + 1
    return leaf_idx, self.tree[leaf_idx], data_idx
  • **v**: A random value between 0 and the total priority.

Starts at the root (parent_idx = 0) and traverses down:

  • If v is less than or equal to the left child's priority, move to the left child.
  • Otherwise, subtract the left child’s priority from v and move to the right child.

Returns:

  • **leaf_idx**: The index of the leaf in the tree array.
  • **self.tree[leaf_idx]**: The priority stored at the leaf.
  • **data_idx**: The corresponding index in the data arra

Total Priority (total_priority)

Purpose: Returns the total sum of all priorities in the tree.

def total_priority(self):
    return self.tree[0]  # Root node stores the total sum
  • Simply retrieves the value of the root node, which holds the sum of all priorities.

How These Methods Work Together

Adding Priorities:

  • add(priority) stores a new priority in the tree and updates the corresponding path in the hierarchy.

Sampling Priorities:

  • get_leaf(v) allows efficient sampling by interpreting the tree as a range. For example:
  • If the total priority is 100, a random v = 30 finds a leaf where cumulative priority reaches 30.

Updating Priorities:

  • update(tree_idx, priority) lets you modify the priority of a specific leaf after sampling.

Summing Priorities:

  • total_priority() helps in dividing the priority range for sampling batches.

2. ReplayBuffer: Storing and Sampling Transitions

The ReplayBuffer integrates the SumTree and handles transitions, including sampling, adding, and updating priorities.

class ReplayBuffer:
    def __init__(self, state_dim=206, max_size=600000, alpha=0.4, beta=0.4, min_priority=1e-5):
        self.state_dim = state_dim  # Dimension of the state space
        self.max_size = max_size  # Maximum number of experiences to store
        self.alpha = alpha  # Determines how much prioritization is used
        self.beta = beta  # Importance sampling correction factor
        self.min_priority = min_priority  # Smallest priority to avoid zero sampling probability

        self.sum_tree = SumTree(max_size)

        # Preallocate memory for experiences
        self.states = np.zeros((max_size, state_dim), dtype=np.float32)
        self.actions = np.zeros(max_size, dtype=np.int32)
        self.rewards = np.zeros(max_size, dtype=np.float32)
        self.next_states = np.zeros((max_size, state_dim), dtype=np.float32)
        self.dones = np.zeros(max_size, dtype=np.float32)

        self.size = 0
        self.ptr = 0

    def add(self, state, action, reward, next_state, done, td_error):
        """Add a new experience to the buffer."""
        state = np.array(state, dtype=np.float32).reshape(self.state_dim)
        next_state = np.array(next_state, dtype=np.float32).reshape(self.state_dim)
        priority = (abs(td_error) + self.min_priority) ** self.alpha

        self.states[self.ptr] = state
        self.actions[self.ptr] = action
        self.rewards[self.ptr] = reward
        self.next_states[self.ptr] = next_state
        self.dones[self.ptr] = float(done)

        self.sum_tree.add(priority)

        self.ptr = (self.ptr + 1) % self.max_size
        self.size = min(self.size + 1, self.max_size)

    def sample(self, batch_size):
        """Sample a batch of experiences based on priorities."""
        if self.size < batch_size:
            return None, None, None, None, None, None, None

        segment = self.sum_tree.total_priority() / batch_size
        samples_idx = []
        priorities = []

        for i in range(batch_size):
            a = segment * i
            b = segment * (i + 1)
            a = max(a, 0.0)
            b = min(b, self.sum_tree.total_priority())

            if a >= b:
                b = a + 1e-6
            s = np.random.uniform(a, b)
            leaf_idx, priority, data_idx = self.sum_tree.get_leaf(s)
            samples_idx.append(leaf_idx)
            priorities.append(priority)

        probabilities = np.array(priorities) / self.sum_tree.total_priority()
        weights = (self.size * probabilities) ** (-self.beta)
        weights /= weights.max()

        samples_idx = np.array(samples_idx, dtype=np.int32)
        state_batch = self.states[samples_idx - (self.sum_tree.capacity - 1)]
        action_batch = self.actions[samples_idx - (self.sum_tree.capacity - 1)]
        reward_batch = self.rewards[samples_idx - (self.sum_tree.capacity - 1)]
        next_state_batch = self.next_states[samples_idx - (self.sum_tree.capacity - 1)]
        done_batch = self.dones[samples_idx - (self.sum_tree.capacity - 1)]

        return state_batch, action_batch, reward_batch, next_state_batch, done_batch, samples_idx, weights

    def update_priorities(self, indices, td_errors):
        """Update priorities for sampled experiences."""
        new_priorities = (np.abs(td_errors) + self.min_priority) ** self.alpha
        for idx, prio in zip(indices, new_priorities):
            self.sum_tree.update(idx, prio)

    def clear_memory(self):
        """Reset the buffer to an empty state."""
        self.states.fill(0)
        self.actions.fill(0)
        self.rewards.fill(0)
        self.next_states.fill(0)
        self.dones.fill(0)
        self.sum_tree = SumTree(self.max_size)
        self.size = 0
        self.ptr = 0

Constructor (__init__)

Purpose: Initializes the ReplayBuffer with a fixed size, state dimension, and parameters for prioritized replay.

def __init__(self, state_dim=206, max_size=600000, alpha=0.4, beta=0.4, min_priority=1e-5):
    self.state_dim = state_dim
    self.max_size = max_size
    self.alpha = alpha
    self.beta = beta
    self.min_priority = min_priority

    self.sum_tree = SumTree(max_size)

    # Allocate memory for states, actions, rewards, next_states, and dones
    self.states = np.zeros((max_size, state_dim), dtype=np.float32)
    self.actions = np.zeros(max_size, dtype=np.int32)
    self.rewards = np.zeros(max_size, dtype=np.float32)
    self.next_states = np.zeros((max_size, state_dim), dtype=np.float32)
    self.dones = np.zeros(max_size, dtype=np.float32)

    self.size = 0  # Number of stored transitions
    self.ptr = 0  # Pointer for the next insertion
  • **state_dim**: Number of features in the state representation.
  • **max_size**: Maximum number of transitions the buffer can hold.
  • **alpha**: Degree of prioritization (higher values make sampling focus more on high-priority transitions).
  • **beta**: Degree of importance sampling correction during training.
  • **min_priority**: Minimum priority to avoid zero probabilities.
  • Buffers (states, actions, etc.): Preallocated arrays for efficient storage of transitions.

Add (add)

Purpose: Adds a single transition to the buffer and updates its priority in the SumTree.

def add(self, state, action, reward, next_state, done, td_error):
    state = np.array(state, dtype=np.float32).reshape(self.state_dim)
    next_state = np.array(next_state, dtype=np.float32).reshape(self.state_dim)
    priority = (abs(td_error) + self.min_priority) ** self.alpha

    self.states[self.ptr] = state
    self.actions[self.ptr] = action
    self.rewards[self.ptr] = reward
    self.next_states[self.ptr] = next_state
    self.dones[self.ptr] = float(done)

    self.sum_tree.add(priority)

    self.ptr = (self.ptr + 1) % self.max_size
    self.size = min(self.size + 1, self.max_size)
  • **state, action, reward, next_state, done**: Components of a single transition.
  • **td_error**: Temporal difference error used to compute the priority.
  • Process:
  1. Computes the priority as (∣td_error∣+min_priority)α(|\text{td_error}| + \text{min_priority})^{\alpha}(∣td_error∣+min_priority)α.
  2. Stores the transition in the buffer.
  3. Updates the SumTree with the computed priority.
  4. Updates the pointer (ptr) and buffer size.

Add Batch (add_batch)

Purpose: Adds multiple transitions to the buffer in one operation.

def add_batch(self, states, actions, rewards, next_states, dones, td_errors):
    batch_size = len(states)
    for i in range(batch_size):
        self.add(states[i], actions[i], rewards[i], next_states[i], dones[i], td_errors[i])
  • Loops through a batch of transitions and calls add for each one.
  • Useful for adding transitions collected in parallel environments or batched training.

Sample (sample)

Purpose: Samples a batch of transitions based on their priorities and returns them with importance sampling weights.

def sample(self, batch_size):
    if self.size < batch_size:
        return None, None, None, None, None, None, None

    segment = self.sum_tree.total_priority() / batch_size
    samples_idx, priorities = [], []

    for i in range(batch_size):
        a = segment * i
        b = segment * (i + 1)
        s = np.random.uniform(a, b)
        leaf_idx, priority, data_idx = self.sum_tree.get_leaf(s)
        samples_idx.append(leaf_idx)
        priorities.append(priority)

    samples_idx = np.array(samples_idx, dtype=np.int32)

    # Retrieve transitions from the buffer
    state_batch = self.states[samples_idx - (self.sum_tree.capacity - 1)]
    action_batch = self.actions[samples_idx - (self.sum_tree.capacity - 1)]
    reward_batch = self.rewards[samples_idx - (self.sum_tree.capacity - 1)]
    next_state_batch = self.next_states[samples_idx - (self.sum_tree.capacity - 1)]
    done_batch = self.dones[samples_idx - (self.sum_tree.capacity - 1)]

    # Calculate sampling probabilities and importance-sampling weights
    probabilities = np.array(priorities) / self.sum_tree.total_priority()
    weights = (self.size * probabilities) ** (-self.beta)
    weights /= weights.max()

    return state_batch, action_batch, reward_batch, next_state_batch, done_batch, samples_idx, weights
  • Steps:
  1. Divides the total priority into batch_size equal segments.
  2. For each segment, selects a random value (s) and uses get_leaf to find the corresponding transition.
  3. Extracts transitions and calculates probabilities and importance-sampling weights.
  • Importance-sampling weights: Compensate for non-uniform sampling during gradient updates.

Update Priorities (update_priorities)

Purpose: Updates the priorities of sampled transitions based on new TD errors.

def update_priorities(self, indices, td_errors):
    new_priorities = (np.abs(td_errors) + self.min_priority) ** self.alpha
    for idx, prio in zip(indices, new_priorities):
        self.sum_tree.update(idx, prio)
  • **indices**: Indices of sampled transitions in the SumTree.
  • **td_errors**: New temporal difference errors to compute updated priorities.
  • Iterates through the sampled indices and updates their priorities.

Clear Memory (clear_memory)

Purpose: Resets the replay buffer and clears all stored transitions and priorities.

def clear_memory(self):
    self.states.fill(0)
    self.actions.fill(0)
    self.rewards.fill(0)
    self.next_states.fill(0)
    self.dones.fill(0)
    self.sum_tree = SumTree(self.max_size)
    self.size = 0
    self.ptr = 0
  • Resets all arrays and reinitializes the SumTree.
  • Useful for restarting training with a clean buffer.

Key Features of the Implementation

  1. Efficient Sampling: Sampling priorities in O(log⁡n)O(\log n)O(logn) time using the SumTree.
  2. Batch Operations: Support for adding and sampling batches of transitions.
  3. Priority Updates: Flexible priority adjustment based on TD errors.

How These Methods Work Together

Adding Transitions:

  • Use add or add_batch to store transitions and their priorities.

Sampling for Training:

  • sample provides a batch of transitions prioritized by TD error, along with importance-sampling weights.

Updating Priorities:

  • After training on a sampled batch, use update_priorities to update priorities with new TD errors.

Clearing Memory:

  • If needed, clear_memory resets the buffer for a fresh start.

Optimization Opportunities

The implementation can be further optimized to handle larger scales more efficiently. For instance, adding GPU support to the ReplayBuffer and SumTree could accelerate operations by leveraging parallel computations with CUDA for priority updates and data handling. Additionally, using specialized memory allocation techniques, such as pre-allocated and optimized memory structures, can minimize cache misses and significantly reduce access times for large datasets. These optimizations are particularly beneficial when managing millions of transitions in real-time systems.

Related Research

The concept of a prioritized replay buffer was first introduced in DeepMind’s seminal 2015 DQN paper, “Human-level control through deep reinforcement learning.” This study demonstrated how focusing on more important transitions significantly improves the agent’s learning process. Since then, many research efforts have refined this approach, incorporating adaptive parameters and more efficient priority management techniques, further enhancing performance across various environments.

Postscript

This blog post is a culmination of research and practical implementation with the invaluable assistance of ChatGPT. The explanations of the SumTree and ReplayBuffer methods, along with the prioritized replay buffer code examples, were developed with guidance from ChatGPT. This collaboration ensures a clear, detailed, and accurate presentation of how to enhance reinforcement learning performance using prioritized experience replay.


메타데이터
post_id
e5455ecc1f67
slug
efficient-experience-replay-with-a-prioritized-replay-buffer-in-dqn-e5455ecc1f67
url
https://medium.com/@velsorange/efficient-experience-replay-with-a-prioritized-replay-buffer-in-dqn-e5455ecc1f67
canonical_url
https://medium.com/@velsorange/efficient-experience-replay-with-a-prioritized-replay-buffer-in-dqn-e5455ecc1f67
author_url
https://medium.com/@velsorange
status
ok
fetched_at
2026-06-15 20:49:13