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…

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:
- SumTree: A binary tree data structure used to efficiently store and sample priorities.
- 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_pointerto 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
vis less than or equal to the left child's priority, move to the left child. - Otherwise, subtract the left child’s priority from
vand 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 = 30finds 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:
- Computes the priority as (∣td_error∣+min_priority)α(|\text{td_error}| + \text{min_priority})^{\alpha}(∣td_error∣+min_priority)α.
- Stores the transition in the buffer.
- Updates the SumTree with the computed priority.
- 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
addfor 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:
- Divides the total priority into
batch_sizeequal segments. - For each segment, selects a random value (
s) and usesget_leafto find the corresponding transition. - 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
- Efficient Sampling: Sampling priorities in O(logn)O(\log n)O(logn) time using the
SumTree. - Batch Operations: Support for adding and sampling batches of transitions.
- Priority Updates: Flexible priority adjustment based on TD errors.
How These Methods Work Together
Adding Transitions:
- Use
addoradd_batchto store transitions and their priorities.
Sampling for Training:
sampleprovides a batch of transitions prioritized by TD error, along with importance-sampling weights.
Updating Priorities:
- After training on a sampled batch, use
update_prioritiesto update priorities with new TD errors.
Clearing Memory:
- If needed,
clear_memoryresets 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