Reinforcement Learning & Decision Making 11 Aug 2026

Stop Sampling Randomly: Mastering Prioritized Experience Replay (PER)

#Reinforcement Learning #Deep Q-Networks #Experience Replay #Temporal-Difference Learning #Importance Sampling #Deep Learning #Atari 2600

Stop Sampling Randomly: Mastering Prioritized Experience Replay (PER)

In the world of Deep Reinforcement Learning (DRL), the Experience Replay Buffer is a cornerstone of stability. By storing past transitions and sampling them randomly, we break the temporal correlation of data and prevent the agent from "forgetting" old experiences.

But there is a fundamental flaw in standard DQN: it treats all memories as equal.

Imagine an agent learning to navigate a complex maze. It spends 99% of its time hitting walls (low-information transitions) and 1% of its time finding the exit (high-information transitions). In a uniform replay buffer, the agent will sample the "hitting walls" experience 99% of the time, drastically slowing down convergence.

Enter Prioritized Experience Replay (PER).


The Core Intuition: Learning from Surprise

The central thesis of PER is that not all experiences are equally valuable. The most useful transitions are those where the agent's current prediction is most wrong.

In RL, this "wrongness" is quantified by the Temporal Difference (TD) Error. A high TD error indicates that the transition was "surprising" and that the agent has the most to learn from it. By prioritizing transitions with higher TD errors, we focus the agent's training on its weakest points.

The Mathematical Framework

To implement this without introducing instability, PER relies on three key formulas:

  1. The Priority Score: We use the magnitude of the TD error $\delta$ as the priority $p_i$. To ensure no transition has zero probability of being sampled, we add a small constant $\epsilon$: $$\text{Proportional Prioritization: } p_i = |\delta_i| + \epsilon$$

  2. Sampling Probability: To control how aggressive the prioritization is, we introduce a hyperparameter $\alpha$. When $\alpha=0$, we have uniform sampling; when $\alpha=1$, we have full prioritization: $$P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha}$$

  3. Importance Sampling (IS) Weights: Prioritizing high-error samples introduces a bias—the model thinks high-error states are more common than they actually are. To correct this, we scale the gradient update using IS weights: $$w_i = \left( \frac{1}{N} \cdot \frac{1}{P(i)} \right)^\beta$$ Where $\beta$ is annealed from an initial value to 1.0 over time.


System Architecture

Implementing PER efficiently requires more than just a list; it requires a data structure that allows for fast sampling and fast updates. The SumTree is the gold standard here, reducing the complexity of sampling and updating from $O(N)$ to $O(\log N)$.

flowchart TD subgraph Environment_Interaction ["Environment Interaction Loop"] Env["Gym Environment"] -->|State| Agent["PER Agent"] Agent -->|Action| Env Env -->|Reward, Next State, Done| Agent end subgraph PER_Buffer ["Prioritized Replay Buffer"] direction TB Input_Trans["New Transition (s, a, r, s', d)"] --> Add_MaxP["Assign Max Priority (p_max^α)"] Add_MaxP --> SumTree_Store["SumTree Storage (O(log N))"] SumTree_Store --> Sample_Logic["Stochastic Sampling (Segmented Range)"] Sample_Logic --> IS_Calc["Importance Sampling Weight Calculation (β)"] IS_Calc --> Sample_Out["Sampled Batch + IS Weights + Indices"] end subgraph Learning_Pipeline ["Learning Pipeline"] Sample_Out --> Q_Eval["Q-Network Evaluation"] Q_Eval --> TD_Calc["Calculate TD Error: |Target - Current|"] TD_Calc --> Loss_Calc["Weighted Loss Calculation (Loss * IS_Weight)"] Loss_Calc --> Backprop["Backpropagation & Optimizer"] Backprop --> Model_Update["Update Q-Network Weights"] end %% Feedback Loop for Priorities Model_Update -.->|New TD Errors| Update_P["Update Priorities in SumTree"] Update_P -.-> SumTree_Store %% Connections between main blocks Agent -->|Push Transition| Input_Trans Sample_Out --> Learning_Pipeline Learning_Pipeline -->|Updated Model| Agent %% Styling style PER_Buffer fill:#f9f,stroke:#333,stroke-width:2px style Learning_Pipeline fill:#bbf,stroke:#333,stroke-width:2px style Environment_Interaction fill:#dfd,stroke:#333,stroke-width:2px

Production-Ready Implementation

Below is the complete implementation using PyTorch and OpenAI Gym. Note the use of the SumTree to maintain the priority distribution.

PYTHON
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import random
from collections import namedtuple
import gym

Transition = namedtuple('Transition', ('state', 'action', 'next_state', 'reward', 'done'))

class SumTree:
    """Binary tree where each node is the sum of its children for O(log N) sampling."""
    def __init__(self, capacity):
        self.capacity = capacity
        self.tree = np.zeros(2 * capacity - 1)
        self.data = np.zeros(capacity, dtype=object)
        self.write = 0
        self.n_entries = 0

    def _propagate(self, idx, change):
        parent = (idx - 1) // 2
        self.tree[parent] += change
        if parent != 0:
            self._propagate(parent, change)

    def update(self, idx, p):
        change = p - self.tree[idx]
        self.tree[idx] = p
        self._propagate(idx, change)

    def add(self, p, data):
        idx = self.write + self.capacity - 1
        self.data[self.write] = data
        self.update(idx, p)
        self.write = (self.write + 1) % self.capacity
        if self.n_entries < self.capacity:
            self.n_entries += 1

    def get(self, s):
        idx = self._retrieve(0, s)
        data_idx = idx - self.capacity + 1
        return (idx, self.tree[idx], self.data[data_idx])

    def _retrieve(self, idx, s):
        left = 2 * idx + 1
        right = left + 1
        if left >= len(self.tree):
            return idx
        if s <= self.tree[left]:
            return self._retrieve(left, s)
        else:
            return self._retrieve(right, s - self.tree[left])

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

class PrioritizedReplayBuffer:
    def __init__(self, capacity, alpha=0.6, beta=0.4, beta_increment=0.001):
        self.tree = SumTree(capacity)
        self.alpha = alpha 
        self.beta = beta    
        self.beta_increment = beta_increment
        self.max_p = 1.0    

    def push(self, state, action, next_state, reward, done):
        data = Transition(state, action, next_state, reward, done)
        self.tree.add(self.max_p**self.alpha, data)

    def sample(self, batch_size):
        batch, idxs, priorities = [], [], []
        segment = self.tree.total_priority / batch_size
        self.beta = np.min([1., self.beta + self.beta_increment])

        for i in range(batch_size):
            s = random.uniform(segment * i, segment * (i + 1))
            (idx, p, data) = self.tree.get(s)
            priorities.append(p)
            idxs.append(idx)
            batch.append(data)

        sampling_probabilities = np.array(priorities) / self.tree.total_priority
        is_weights = np.power(self.tree.n_entries * sampling_probabilities, -self.beta)
        is_weights /= is_weights.max() 

        return batch, idxs, torch.FloatTensor(is_weights)

    def update_priorities(self, idxs, errors):
        for idx, error in zip(idxs, errors):
            p = (np.abs(error) + 1e-5)**self.alpha
            self.tree.update(idx, p)
            self.max_p = max(self.max_p, np.abs(error) + 1e-5)

class QNetwork(nn.Module):
    def __init__(self, state_dim, action_dim):
        super(QNetwork, self).__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, 64), nn.ReLU(),
            nn.Linear(64, 64), nn.ReLU(),
            nn.Linear(64, action_dim)
        )
    def forward(self, x): return self.net(x)

class PERAgent:
    def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99):
        self.state_dim, self.action_dim, self.gamma = state_dim, action_dim, gamma
        self.model = QNetwork(state_dim, action_dim)
        self.target_model = QNetwork(state_dim, action_dim)
        self.target_model.load_state_dict(self.model.state_dict())
        self.optimizer = optim.Adam(self.model.parameters(), lr=lr)
        self.memory = PrioritizedReplayBuffer(capacity=10000)

    def select_action(self, state, epsilon=0.1):
        if random.random() < epsilon:
            return random.randint(0, self.action_dim - 1)
        state = torch.FloatTensor(state).unsqueeze(0)
        with torch.no_grad():
            return self.model(state).argmax().item()

    def train_step(self, batch_size):
        if self.memory.tree.n_entries < batch_size: return None

        batch, idxs, is_weights = self.memory.sample(batch_size)
        
        states = torch.FloatTensor(np.array([t.state for t in batch]))
        actions = torch.LongTensor(np.array([t.action for t in batch])).unsqueeze(1)
        next_states = torch.FloatTensor(np.array([t.next_state for t in batch]))
        rewards = torch.FloatTensor(np.array([t.reward for t in batch])).unsqueeze(1)
        dones = torch.FloatTensor(np.array([t.done for t in batch])).unsqueeze(1)

        curr_q = self.model(states).gather(1, actions)
        with torch.no_grad():
            next_q = self.target_model(next_states).max(1)[0].unsqueeze(1)
            target_q = rewards + (1 - dones) * self.gamma * next_q

        td_errors = (target_q - curr_q).detach().squeeze().numpy()
        
        # Apply Importance Sampling weights to the loss
        loss = (is_weights.unsqueeze(1) * nn.MSELoss(reduction='none')(curr_q, target_q)).mean()

        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()

        self.memory.update_priorities(idxs, td_errors)
        return loss.item()

    def update_target(self):
        self.target_model.load_state_dict(self.model.state_dict())

Key Takeaways for Implementation

1. The $\alpha$ and $\beta$ Trade-off

  • $\alpha$ (Prioritization Degree): If you set $\alpha$ too high, the agent may overfit to a small set of "hard" samples, leading to instability. Start with $0.6$.
  • $\beta$ (Bias Correction): Bias correction is most critical at the end of training. This is why we anneal $\beta$ from $0.4 \to 1.0$.

2. Complexity Analysis

Operation Uniform Replay Prioritized Replay (SumTree)
Push $O(1)$ $O(\log N)$
Sample $O(1)$ $O(\log N)$
Update N/A $O(\log N)$

3. When to use PER?

PER is most effective in sparse reward environments. If your agent only receives a reward once every 1,000 steps, PER ensures that the single successful trajectory is replayed frequently enough to propagate the value back to the starting state.