강의자료 · 전기공학을 위한 강화학습

Prioritized Experience Replay (PER)

[기존 Replay Buffer의 문제점]

[Prioritized Experience Replay (PER)의 핵심 개념: 중요한 경험을 더 많이 사용]

δi=ri+γmaxa′Q(si′,a′;θ−)−Q(si,ai;θ) \delta_i = r_i + \gamma \max_{a'} Q(s'_i, a'; \theta^{-}) - Q(s_i, a_i; \theta)

[중요도 가중치 (Importance Sampling Weight): PER의 편향 잡기]

[PER의 효과]

[실습: Dueling Double DQN with PER (Prioritized Experience Replay)]

1. 라이브러리 가져오기

import gymnasium as gym
import torch
import torch.nn as nn
import torch.optim as optim
import random
from collections import deque
import imageio
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt

2. GPU 설정 및 하이퍼파라미터 정의

# GPU 사용 가능 여부 확인 및 장치 설정
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 하이퍼파라미터 (CartPole 환경에 맞게 조정)
BATCH_SIZE = 128
GAMMA = 0.99
EPS_START = 1.0
EPS_END = 0.01
EPS_DECAY = 0.995
TARGET_UPDATE = 5
MEMORY_SIZE = 30000
LR = 0.0003
NUM_EPISODES = 2000
PROGRESS_INTERVAL = 50

# PER 관련 하이퍼파라미터
ALPHA = 0.6
BETA_START = 0.4
BETA_DECAY = 0.999

3. Dueling DQN 네트워크 정의

class DuelingDQN(nn.Module):
    def __init__(self, state_size, action_size, hidden_size):
        super(DuelingDQN, self).__init__()
        self.feature_layer = nn.Sequential(
            nn.Linear(state_size, hidden_size),
            nn.ReLU()
        )
        self.value_stream = nn.Sequential(
            nn.Linear(hidden_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, 1)
        )
        self.advantage_stream = nn.Sequential(
            nn.Linear(hidden_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, action_size)
        )

    def forward(self, state):
        x = self.feature_layer(state)
        value = self.value_stream(x)
        advantage = self.advantage_stream(x)
        q_values = value + (advantage - advantage.mean(dim=1, keepdim=True))
        return q_values

4. Prioritized Experience Replay (PER) 구현

class PrioritizedReplayBuffer:
    def __init__(self, capacity, alpha=ALPHA):
        self.capacity = capacity
        self.alpha = alpha
        self.buffer = deque(maxlen=capacity)
        self.priorities = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        state = np.array(state)
        next_state = np.array(next_state)
        max_priority = max(self.priorities, default=1) # 처음엔 1, 이후엔 max
        self.buffer.append((state, action, reward, next_state, done))
        self.priorities.append(max_priority)

    def sample(self, batch_size, beta=BETA_START):
        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)
        samples = [self.buffer[idx] for idx in indices]

        total = len(self.buffer)
        weights = (total * probs[indices]) ** (-beta)
        weights /= weights.max()  # Normalize for stability
        weights = torch.tensor(weights, dtype=torch.float32, device=device)

        state, action, reward, next_state, done = zip(*samples)

        return torch.tensor(np.array(state), dtype=torch.float32, device=device), \
               torch.tensor(action, dtype=torch.int64, device=device), \
               torch.tensor(reward, dtype=torch.float32, device=device), \
               torch.tensor(np.array(next_state), dtype=torch.float32, device=device), \
               torch.tensor(done, dtype=torch.float32, device=device), \
               weights, indices # 중요도 가중치, 샘플링된 인덱스 반환

    def update_priorities(self, indices, errors, offset=0.1):
        for idx, error in zip(indices, errors):
            self.priorities[idx] = (abs(error) + offset) ** self.alpha

    def __len__(self):
        return len(self.buffer)

5. 학습 함수 정의

def train(env, policy_net, target_net, optimizer, replay_buffer, num_episodes=NUM_EPISODES):
    all_rewards = []
    epsilon = EPS_START
    beta = BETA_START
    for episode in tqdm(range(num_episodes), desc="Training"):
        state, _ = env.reset()
        total_reward = 0
        done = False

        while not done:
            # Epsilon-Greedy Action Selection
            if random.random() > epsilon:
                with torch.no_grad():
                    state_tensor = torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0)
                    q_values = policy_net(state_tensor)
                    action = q_values.argmax(dim=1).item()
            else:
                action = env.action_space.sample()

            next_state, reward, terminated, truncated, _ = env.step(action)
            total_reward += reward
            done = terminated or truncated

            # Replay Buffer에 경험 저장
            replay_buffer.push(state, action, reward, next_state, done)
            state = next_state

            # Replay Buffer에서 샘플링 및 학습
            if len(replay_buffer) >= BATCH_SIZE:
                state_batch, action_batch, reward_batch, next_state_batch, done_batch, weights, indices = replay_buffer.sample(BATCH_SIZE, beta)

                # Double DQN Target Q-value 계산
                with torch.no_grad():
                    next_actions = policy_net(next_state_batch).argmax(dim=1, keepdim=True)
                    next_q_values = target_net(next_state_batch).gather(1, next_actions).squeeze(1)
                    target_q_values = reward_batch + (1 - done_batch) * GAMMA * next_q_values

                # 현재 Q-value 계산
                q_values = policy_net(state_batch)
                q_values_for_actions = q_values.gather(1, action_batch.unsqueeze(1)).squeeze(1)

                # Loss 계산 및 최적화
                errors = (target_q_values - q_values_for_actions).detach().cpu().numpy()
                replay_buffer.update_priorities(indices, errors) # update priorities
                loss = (weights * (q_values_for_actions - target_q_values)**2).mean()

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

        # Target Network 업데이트
        if (episode+1) % TARGET_UPDATE == 0:
            target_net.load_state_dict(policy_net.state_dict())

        # epsilon, beta 업데이트
        epsilon = max(EPS_END, epsilon * EPS_DECAY)
        beta = min(1.0, beta * BETA_DECAY)

        # 평균 리워드 출력
        all_rewards.append(total_reward)
        if (episode+1) % PROGRESS_INTERVAL == 0:
            print(f"Episode {episode+1}: Average Reward = {np.mean(all_rewards[-PROGRESS_INTERVAL:])}")

        # 오랜 기간 리워드가 충분히 크다면 학습 종료
        if np.min(all_rewards[-TARGET_UPDATE*2:]) >= 450:
            print(f"Solved in {episode+1} episodes!")
            break

    return all_rewards

6. 학습 실행 및 결과 확인

env = gym.make("CartPole-v1", render_mode='rgb_array')
state_size = env.observation_space.shape[0]
action_size = env.action_space.n
hidden_size = 128

policy_net = DuelingDQN(state_size, action_size, hidden_size).to(device)
target_net = DuelingDQN(state_size, action_size, hidden_size).to(device)
target_net.load_state_dict(policy_net.state_dict())
target_net.eval()

optimizer = optim.Adam(policy_net.parameters(), lr=LR)
replay_buffer = PrioritizedReplayBuffer(MEMORY_SIZE)

all_rewards = train(env, policy_net, target_net, optimizer, replay_buffer)

# 학습 결과 그래프 그리기
plt.figure(figsize=(10, 5))
plt.plot(all_rewards)
plt.title('Rewards per Episode')
plt.xlabel('Episode')
plt.ylabel('Reward')
plt.show()
Training:   3%|▎         | 51/2000 [00:08<06:17,  5.17it/s]
Episode 50: Average Reward = 27.52
Training:   5%|▌         | 100/2000 [00:23<09:17,  3.41it/s]
Episode 100: Average Reward = 50.72
Training:   8%|▊         | 150/2000 [00:42<10:35,  2.91it/s]
Episode 150: Average Reward = 55.6
Training:  10%|█         | 200/2000 [01:15<18:20,  1.64it/s]
Episode 200: Average Reward = 89.44
Training:  12%|█▎        | 250/2000 [02:05<38:10,  1.31s/it]
Episode 250: Average Reward = 114.56
Training:  15%|█▌        | 300/2000 [03:01<42:24,  1.50s/it]
Episode 300: Average Reward = 113.38
Training:  18%|█▊        | 350/2000 [04:23<55:10,  2.01s/it]
Episode 350: Average Reward = 141.38
Training:  20%|██        | 400/2000 [06:13<38:40,  1.45s/it]
Episode 400: Average Reward = 168.92
Training:  22%|██▎       | 450/2000 [08:33<1:59:55,  4.64s/it]
Episode 450: Average Reward = 218.8
Training:  25%|██▌       | 500/2000 [12:14<2:22:00,  5.68s/it]
Episode 500: Average Reward = 355.84
Training:  28%|██▊       | 550/2000 [15:57<1:14:46,  3.09s/it]
Episode 550: Average Reward = 344.56
Training:  30%|███       | 600/2000 [19:10<2:04:54,  5.35s/it]
Episode 600: Average Reward = 309.68
Training:  32%|███▎      | 650/2000 [22:30<1:47:21,  4.77s/it]
Episode 650: Average Reward = 322.32
Training:  35%|███▌      | 700/2000 [26:07<1:39:18,  4.58s/it]
Episode 700: Average Reward = 347.04
Training:  38%|███▊      | 750/2000 [29:52<1:51:12,  5.34s/it]
Episode 750: Average Reward = 357.04
Training:  38%|███▊      | 756/2000 [30:36<50:22,  2.43s/it]  
Solved in 757 episodes!
<Figure size 1000x500 with 1 Axes>
실행 결과
# 최종 결과 플레이 렌더링 및 GIF로 저장
frames = []
state, _ = env.reset()
done = False
total_reward = 0

while not done:
    with torch.no_grad():
        state_tensor = torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0)
        q_values = policy_net(state_tensor)
        action = q_values.argmax(dim=1).item()
    next_state, reward, terminated, truncated, _ = env.step(action)
    total_reward += reward
    state = next_state
    done = terminated or truncated
    frames.append(env.render())

imageio.mimsave('cartpole_dueling_double_dqn_per.gif', frames, duration=33)
print(f"Validation Episode Reward: {total_reward}")
env.close()
Validation Episode Reward: 500.0