
DQN 변형 알고리즘: Double DQN부터 Rainbow까지
DQN 변형 알고리즘: Double DQN부터 Rainbow까지
기본 DQN이 혁신적이었지만, 여러 한계점이 있었습니다. 연구자들은 이를 개선하기 위해 다양한 변형 알고리즘을 제안했고, 결국 모든 개선점을 통합한 Rainbow가 탄생했습니다. 이 글에서는 각 변형의 핵심 아이디어와 구현 방법을 상세히 살펴봅니다.
1. DQN의 한계점
기본 DQN을 사용하다 보면 다음과 같은 문제를 마주하게 됩니다:
| 문제 | 설명 | 해결책 |
|---|---|---|
| Q값 과대평가 | max 연산이 노이즈를 증폭시켜 Q값이 실제보다 높게 추정됨 | Double DQN |
| 비효율적인 표현 | 모든 행동의 Q값을 개별적으로 학습 | Dueling DQN |
| 균등한 샘플링 | 중요한 경험이나 별로 중요하지 않은 경험을 동일하게 취급 | Prioritized Experience Replay |
| 단일 스텝 학습 | 한 스텝의 보상만 사용 | Multi-step Learning |
| 점 추정 | Q값의 분포가 아닌 기대값만 학습 | Distributional RL |
| ε-greedy의 한계 | 탐험이 비효율적 | Noisy Networks |
2. Double DQN (DDQN)
문제: Q값 과대평가 (Overestimation)
기본 DQN에서 타겟 계산:
target = reward + gamma * max(Q_target(next_state))
max 연산은 노이즈가 있을 때 Q값을 과대평가합니다. 여러 행동 중 가장 높은 값을 선택하면, 그 중 하나라도 우연히 높게 추정되면 그 값이 사용되기 때문입니다.
해결책: 행동 선택과 평가 분리
Double DQN은 행동 선택과 행동 평가를 다른 네트워크로 수행합니다:
# DQN (기존) # 타겟 네트워크가 행동 선택 + 평가 모두 수행 next_q_values = target_net(next_states) target = reward + gamma * next_q_values.max(1)[0] # Double DQN (개선) # 메인 네트워크: 행동 선택 # 타겟 네트워크: 선택된 행동 평가 best_actions = policy_net(next_states).argmax(1, keepdim=True) next_q_values = target_net(next_states).gather(1, best_actions).squeeze() target = reward + gamma * next_q_values
완전한 Double DQN 구현
import torch import torch.nn as nn import torch.optim as optim import numpy as np from collections import deque import random class DoubleDQN(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.network = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim) ) def forward(self, x): return self.network(x) class DoubleDQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99): self.action_dim = action_dim self.gamma = gamma self.policy_net = DoubleDQN(state_dim, action_dim) self.target_net = DoubleDQN(state_dim, action_dim) self.target_net.load_state_dict(self.policy_net.state_dict()) self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr) self.memory = deque(maxlen=10000) def update(self, batch_size=64): if len(self.memory) < batch_size: return batch = random.sample(self.memory, batch_size) states, actions, rewards, next_states, dones = zip(*batch) states = torch.FloatTensor(np.array(states)) actions = torch.LongTensor(actions).unsqueeze(1) rewards = torch.FloatTensor(rewards) next_states = torch.FloatTensor(np.array(next_states)) dones = torch.FloatTensor(dones) # 현재 Q값 current_q = self.policy_net(states).gather(1, actions).squeeze() # Double DQN 타겟 계산 with torch.no_grad(): # 1. 메인 네트워크로 최적 행동 선택 best_actions = self.policy_net(next_states).argmax(1, keepdim=True) # 2. 타겟 네트워크로 해당 행동의 Q값 평가 next_q = self.target_net(next_states).gather(1, best_actions).squeeze() target_q = rewards + self.gamma * next_q * (1 - dones) # 손실 계산 및 역전파 loss = nn.MSELoss()(current_q, target_q) self.optimizer.zero_grad() loss.backward() self.optimizer.step() return loss.item()
효과
연구에 따르면 Double DQN은 아타리 게임에서 DQN 대비 평균적으로 더 높은 점수를 달성했으며, 특히 Q값 과대평가가 심했던 게임에서 큰 개선을 보였습니다.
3. Dueling DQN
핵심 아이디어: Q = V + A
기본 DQN은 각 상태-행동 쌍의 Q값을 개별적으로 학습합니다. 하지만 생각해보면:
- 어떤 상태는 어떤 행동을 하든 좋거나 나쁨 (상태 자체의 가치)
- 어떤 행동은 특정 상태에서만 특별히 좋거나 나쁨 (행동의 이점)
Dueling DQN은 이를 분리합니다:
- V(s): 상태 가치 (State Value) - 상태가 얼마나 좋은가
- A(s, a): 이점 (Advantage) - 해당 행동이 평균 대비 얼마나 좋은가
마지막 항은 A의 평균을 빼서 식별성(identifiability) 문제를 해결합니다.
구조
입력 (상태) ↓ 공유 CNN/FC 레이어 ↓ ├── Value Stream → V(s) [1개 출력] │ └── Advantage Stream → A(s,a) [행동 수만큼 출력] ↓ Q(s,a) = V(s) + A(s,a) - mean(A)
구현
class DuelingDQN(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() # 공유 특징 추출 레이어 self.feature = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU() ) # Value Stream: V(s) self.value_stream = nn.Sequential( nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 1) # 단일 값 출력 ) # Advantage Stream: A(s, a) self.advantage_stream = nn.Sequential( nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, action_dim) # 각 행동별 이점 ) def forward(self, x): features = self.feature(x) value = self.value_stream(features) # [batch, 1] advantage = self.advantage_stream(features) # [batch, action_dim] # Q = V + (A - mean(A)) # advantage에서 평균을 빼서 식별성 확보 q_values = value + (advantage - advantage.mean(dim=1, keepdim=True)) return q_values # CNN 버전 (이미지 입력용) class DuelingDQN_CNN(nn.Module): def __init__(self, action_dim): super().__init__() # 공유 CNN (아타리 스타일) self.conv = nn.Sequential( nn.Conv2d(4, 32, kernel_size=8, stride=4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size=4, stride=2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size=3, stride=1), nn.ReLU() ) conv_out_size = 64 * 7 * 7 # Value Stream self.value = nn.Sequential( nn.Linear(conv_out_size, 512), nn.ReLU(), nn.Linear(512, 1) ) # Advantage Stream self.advantage = nn.Sequential( nn.Linear(conv_out_size, 512), nn.ReLU(), nn.Linear(512, action_dim) ) def forward(self, x): x = x / 255.0 conv_out = self.conv(x).view(x.size(0), -1) value = self.value(conv_out) advantage = self.advantage(conv_out) return value + (advantage - advantage.mean(dim=1, keepdim=True))
직관적 이해
게임에서 예를 들면:
- V(s): "지금 체력이 만땅이고 적이 멀리 있어서 안전한 상태다" → 상태 가치 높음
- A(s, 공격): "하지만 지금은 공격보다 회피가 더 유리하다" → 공격의 이점은 낮음
이렇게 분리하면 많은 상태에서 행동과 무관하게 좋은/나쁜 상태를 더 빨리 학습할 수 있습니다.
4. Prioritized Experience Replay (PER)
문제: 모든 경험이 동등한가?
기본 Experience Replay는 모든 경험을 균등하게 샘플링합니다. 하지만:
- 희귀하고 중요한 경험 (예: 처음 골인한 경험)
- 이미 잘 학습된 평범한 경험
이 둘을 같은 확률로 샘플링하는 것은 비효율적입니다.
해결책: TD 오차 기반 우선순위
TD 오차가 큰 경험 = 예측이 크게 틀린 경험 = 더 많이 배울 수 있는 경험
- α: 우선순위 지수 (0이면 균등 샘플링, 1이면 완전 우선순위)
- ε: 작은 양수 (TD 오차가 0이어도 샘플링 가능하게)
중요도 샘플링 가중치 (IS Weight)
우선순위 샘플링은 분포를 바꾸므로, 편향을 보정해야 합니다:
β는 0에서 시작하여 학습이 진행될수록 1로 증가시킵니다.
구현: Sum Tree 자료구조
효율적인 우선순위 샘플링을 위해 Sum Tree를 사용합니다:
import numpy as np class SumTree: """ Sum Tree: O(log n) 시간에 우선순위 기반 샘플링 - 리프 노드: 각 경험의 우선순위 - 내부 노드: 자식들의 합 - 루트: 전체 우선순위 합 """ def __init__(self, capacity): self.capacity = capacity self.tree = np.zeros(2 * capacity - 1) # 완전 이진 트리 self.data = np.zeros(capacity, dtype=object) self.write_idx = 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 _retrieve(self, idx, s): """우선순위 합 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]) def total(self): return self.tree[0] def add(self, priority, data): """새 경험 추가""" idx = self.write_idx + self.capacity - 1 self.data[self.write_idx] = data self.update(idx, priority) self.write_idx = (self.write_idx + 1) % self.capacity self.n_entries = min(self.n_entries + 1, self.capacity) def update(self, idx, priority): """우선순위 업데이트""" change = priority - self.tree[idx] self.tree[idx] = priority self._propagate(idx, change) def get(self, s): """우선순위 합 s에 해당하는 (인덱스, 우선순위, 데이터) 반환""" idx = self._retrieve(0, s) data_idx = idx - self.capacity + 1 return idx, self.tree[idx], self.data[data_idx] class PrioritizedReplayBuffer: """우선순위 경험 재생 버퍼""" def __init__(self, capacity, alpha=0.6, beta_start=0.4, beta_frames=100000): self.tree = SumTree(capacity) self.capacity = capacity self.alpha = alpha self.beta_start = beta_start self.beta_frames = beta_frames self.frame = 1 self.epsilon = 1e-6 def beta(self): """β를 점진적으로 1까지 증가""" return min(1.0, self.beta_start + self.frame * (1.0 - self.beta_start) / self.beta_frames) def push(self, state, action, reward, next_state, done): """최대 우선순위로 새 경험 추가""" max_priority = np.max(self.tree.tree[-self.capacity:]) if max_priority == 0: max_priority = 1.0 experience = (state, action, reward, next_state, done) self.tree.add(max_priority, experience) def sample(self, batch_size): """우선순위 기반 샘플링""" batch = [] indices = [] priorities = [] segment = self.tree.total() / batch_size self.frame += 1 beta = self.beta() for i in range(batch_size): a = segment * i b = segment * (i + 1) s = np.random.uniform(a, b) idx, priority, data = self.tree.get(s) batch.append(data) indices.append(idx) priorities.append(priority) # 중요도 샘플링 가중치 계산 sampling_probs = np.array(priorities) / self.tree.total() weights = (self.tree.n_entries * sampling_probs) ** (-beta) weights /= weights.max() # 정규화 states, actions, rewards, next_states, dones = zip(*batch) return ( np.array(states), np.array(actions), np.array(rewards), np.array(next_states), np.array(dones), indices, weights ) def update_priorities(self, indices, td_errors): """TD 오차 기반으로 우선순위 업데이트""" for idx, td_error in zip(indices, td_errors): priority = (abs(td_error) + self.epsilon) ** self.alpha self.tree.update(idx, priority) def __len__(self): return self.tree.n_entries
PER을 적용한 학습 루프
def train_with_per(agent, buffer, batch_size=64): if len(buffer) < batch_size: return # 우선순위 기반 샘플링 states, actions, rewards, next_states, dones, indices, weights = buffer.sample(batch_size) # 텐서 변환 states = torch.FloatTensor(states) actions = torch.LongTensor(actions).unsqueeze(1) rewards = torch.FloatTensor(rewards) next_states = torch.FloatTensor(next_states) dones = torch.FloatTensor(dones) weights = torch.FloatTensor(weights) # Q값 계산 current_q = agent.policy_net(states).gather(1, actions).squeeze() with torch.no_grad(): next_q = agent.target_net(next_states).max(1)[0] target_q = rewards + agent.gamma * next_q * (1 - dones) # TD 오차 계산 (우선순위 업데이트용) td_errors = (current_q - target_q).detach().cpu().numpy() # 중요도 가중치를 적용한 손실 loss = (weights * (current_q - target_q) ** 2).mean() agent.optimizer.zero_grad() loss.backward() agent.optimizer.step() # 우선순위 업데이트 buffer.update_priorities(indices, td_errors) return loss.item()
5. Multi-step Learning
아이디어: n-스텝 부트스트래핑
기본 DQN은 1-스텝 TD 학습을 사용합니다:
Multi-step은 n-스텝 보상을 사용합니다:
장단점
| 장점 | 단점 |
|---|---|
| 보상 신호가 더 빨리 전파됨 | Off-policy 학습과 충돌 가능 |
| 부트스트래핑 편향 감소 | n이 크면 분산 증가 |
구현
class NStepReplayBuffer: def __init__(self, capacity, n_step=3, gamma=0.99): self.capacity = capacity self.n_step = n_step self.gamma = gamma self.buffer = deque(maxlen=capacity) self.n_step_buffer = deque(maxlen=n_step) def _get_n_step_info(self): """n-스텝 보상과 마지막 상태 계산""" reward, next_state, done = self.n_step_buffer[-1][-3:] # 역순으로 n-스텝 보상 계산 for transition in reversed(list(self.n_step_buffer)[:-1]): r, ns, d = transition[-3:] reward = r + self.gamma * reward * (1 - d) if d: next_state, done = ns, d return reward, next_state, done def push(self, state, action, reward, next_state, done): self.n_step_buffer.append((state, action, reward, next_state, done)) if len(self.n_step_buffer) < self.n_step: return # n-스텝 전이 계산 n_step_reward, n_step_next_state, n_step_done = self._get_n_step_info() first_state, first_action = self.n_step_buffer[0][:2] self.buffer.append(( first_state, first_action, n_step_reward, n_step_next_state, n_step_done )) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) return zip(*batch) def __len__(self): return len(self.buffer)
6. Distributional RL (C51)
아이디어: Q값의 분포 학습
기본 DQN은 Q값의 기대값만 학습합니다. 하지만 같은 기대값이라도 분포가 다를 수 있습니다:
- 행동 A: 항상 보상 5 (분산 0)
- 행동 B: 50% 확률로 0, 50% 확률로 10 (기대값 5, 분산 높음)
Distributional RL은 Q값의 전체 분포를 학습합니다.
C51 알고리즘
보상의 분포를 51개의 이산 원자(atom)로 근사합니다:
class C51Network(nn.Module): def __init__(self, state_dim, action_dim, n_atoms=51, v_min=-10, v_max=10): super().__init__() self.action_dim = action_dim self.n_atoms = n_atoms self.v_min = v_min self.v_max = v_max self.delta_z = (v_max - v_min) / (n_atoms - 1) self.support = torch.linspace(v_min, v_max, n_atoms) self.network = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim * n_atoms) ) def forward(self, x): batch_size = x.size(0) # 각 행동에 대한 분포 출력 [batch, action * atoms] logits = self.network(x) logits = logits.view(batch_size, self.action_dim, self.n_atoms) # 소프트맥스로 확률 분포 변환 probs = torch.softmax(logits, dim=2) return probs def get_q_values(self, x): """분포에서 Q값(기대값) 계산""" probs = self(x) support = self.support.to(x.device) q_values = (probs * support.unsqueeze(0).unsqueeze(0)).sum(dim=2) return q_values
7. Noisy Networks
아이디어: 네트워크에 노이즈 주입
ε-greedy 대신 네트워크 파라미터에 학습 가능한 노이즈를 추가하여 탐험합니다.
구현
class NoisyLinear(nn.Module): """노이즈가 있는 선형 레이어""" def __init__(self, in_features, out_features, sigma_init=0.5): super().__init__() self.in_features = in_features self.out_features = out_features self.sigma_init = sigma_init # 평균 파라미터 (학습됨) self.weight_mu = nn.Parameter(torch.empty(out_features, in_features)) self.bias_mu = nn.Parameter(torch.empty(out_features)) # 노이즈 스케일 파라미터 (학습됨) self.weight_sigma = nn.Parameter(torch.empty(out_features, in_features)) self.bias_sigma = nn.Parameter(torch.empty(out_features)) # 노이즈 (샘플링됨, 학습 안 됨) self.register_buffer('weight_epsilon', torch.empty(out_features, in_features)) self.register_buffer('bias_epsilon', torch.empty(out_features)) self.reset_parameters() self.reset_noise() def reset_parameters(self): mu_range = 1 / np.sqrt(self.in_features) self.weight_mu.data.uniform_(-mu_range, mu_range) self.bias_mu.data.uniform_(-mu_range, mu_range) self.weight_sigma.data.fill_(self.sigma_init / np.sqrt(self.in_features)) self.bias_sigma.data.fill_(self.sigma_init / np.sqrt(self.out_features)) def reset_noise(self): """새로운 노이즈 샘플링""" epsilon_in = self._scale_noise(self.in_features) epsilon_out = self._scale_noise(self.out_features) self.weight_epsilon.copy_(epsilon_out.outer(epsilon_in)) self.bias_epsilon.copy_(epsilon_out) def _scale_noise(self, size): x = torch.randn(size) return x.sign() * x.abs().sqrt() def forward(self, x): if self.training: weight = self.weight_mu + self.weight_sigma * self.weight_epsilon bias = self.bias_mu + self.bias_sigma * self.bias_epsilon else: weight = self.weight_mu bias = self.bias_mu return F.linear(x, weight, bias) class NoisyDQN(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc1 = nn.Linear(state_dim, 128) self.fc2 = nn.Linear(128, 128) self.noisy_fc3 = NoisyLinear(128, action_dim) def forward(self, x): x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.noisy_fc3(x) def reset_noise(self): self.noisy_fc3.reset_noise()
8. Rainbow DQN: 모든 것을 합치다
Rainbow = 6가지 개선의 결합
- Double DQN: Q값 과대평가 방지
- Dueling Network: 상태 가치와 이점 분리
- Prioritized Experience Replay: 중요한 경험 우선 학습
- Multi-step Learning: n-스텝 보상 사용
- Distributional RL (C51): Q값 분포 학습
- Noisy Networks: 파라미터 노이즈로 탐험
Rainbow 구조
class RainbowDQN(nn.Module): """ Rainbow DQN: 모든 개선점을 통합한 네트워크 - Dueling Architecture - Noisy Networks - Distributional RL (C51) """ def __init__(self, state_dim, action_dim, n_atoms=51, v_min=-10, v_max=10): super().__init__() self.action_dim = action_dim self.n_atoms = n_atoms self.v_min = v_min self.v_max = v_max self.support = torch.linspace(v_min, v_max, n_atoms) self.delta_z = (v_max - v_min) / (n_atoms - 1) # 공유 특징 추출 self.feature = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU() ) # Dueling + Noisy: Value Stream self.value_hidden = NoisyLinear(128, 128) self.value = NoisyLinear(128, n_atoms) # Dueling + Noisy: Advantage Stream self.advantage_hidden = NoisyLinear(128, 128) self.advantage = NoisyLinear(128, action_dim * n_atoms) def forward(self, x): batch_size = x.size(0) features = self.feature(x) # Value stream value = F.relu(self.value_hidden(features)) value = self.value(value).view(batch_size, 1, self.n_atoms) # Advantage stream advantage = F.relu(self.advantage_hidden(features)) advantage = self.advantage(advantage).view(batch_size, self.action_dim, self.n_atoms) # Dueling aggregation q_atoms = value + advantage - advantage.mean(dim=1, keepdim=True) # 확률 분포로 변환 probs = F.softmax(q_atoms, dim=2) return probs def get_q_values(self, x): probs = self(x) support = self.support.to(x.device) return (probs * support.unsqueeze(0).unsqueeze(0)).sum(dim=2) def reset_noise(self): self.value_hidden.reset_noise() self.value.reset_noise() self.advantage_hidden.reset_noise() self.advantage.reset_noise() class RainbowAgent: def __init__(self, state_dim, action_dim, n_atoms=51, v_min=-10, v_max=10, lr=1e-4, gamma=0.99, n_step=3, alpha=0.6, beta_start=0.4): self.gamma = gamma self.n_step = n_step self.n_atoms = n_atoms self.v_min = v_min self.v_max = v_max self.action_dim = action_dim # 네트워크 self.policy_net = RainbowDQN(state_dim, action_dim, n_atoms, v_min, v_max) self.target_net = RainbowDQN(state_dim, action_dim, n_atoms, v_min, v_max) self.target_net.load_state_dict(self.policy_net.state_dict()) self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr) # PER + N-step 버퍼 (실제로는 둘을 결합한 버퍼 필요) self.memory = PrioritizedReplayBuffer(capacity=100000, alpha=alpha, beta_start=beta_start) self.support = torch.linspace(v_min, v_max, n_atoms) self.delta_z = (v_max - v_min) / (n_atoms - 1) def select_action(self, state): with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.policy_net.get_q_values(state_tensor) return q_values.argmax().item() def update(self, batch_size=32): if len(self.memory) < batch_size: return # PER 샘플링 states, actions, rewards, next_states, dones, indices, weights = self.memory.sample(batch_size) # 텐서 변환 states = torch.FloatTensor(states) actions = torch.LongTensor(actions) rewards = torch.FloatTensor(rewards) next_states = torch.FloatTensor(next_states) dones = torch.FloatTensor(dones) weights = torch.FloatTensor(weights) # Distributional RL 업데이트 (C51) # ... (복잡한 분포 투영 로직) # 노이즈 리셋 self.policy_net.reset_noise() self.target_net.reset_noise() def update_target(self): self.target_net.load_state_dict(self.policy_net.state_dict())
Rainbow의 성능
DeepMind의 연구에 따르면 Rainbow는 아타리 게임에서:
- 기본 DQN 대비 5배 이상 높은 점수
- 개별 개선들의 단순 합보다 더 나은 성능
- 학습 효율성도 크게 향상
9. 각 기법의 기여도
DeepMind의 ablation study 결과:
| 제거된 기법 | 성능 하락 | 중요도 |
|---|---|---|
| Prioritized Replay | 가장 큼 | ⭐⭐⭐⭐⭐ |
| Multi-step | 크게 하락 | ⭐⭐⭐⭐ |
| Distributional | 크게 하락 | ⭐⭐⭐⭐ |
| Noisy Nets | 중간 | ⭐⭐⭐ |
| Dueling | 약간 | ⭐⭐ |
| Double | 가장 작음 | ⭐ |
결론: 모든 기법이 중요하지만, PER과 Multi-step이 특히 핵심적입니다.
10. 실전 팁
언제 어떤 기법을 사용할까?
| 상황 | 추천 기법 |
|---|---|
| 처음 시작 | Double DQN (가장 쉽고 효과적) |
| 학습이 불안정 | Dueling + Double |
| 샘플 효율성 필요 | PER 추가 |
| 장기 보상 중요 | Multi-step 추가 |
| 최고 성능 필요 | Rainbow |
| 리소스 제한 | Double + Dueling만 사용 |
구현 순서 추천
1. 기본 DQN 구현 및 동작 확인 ↓ 2. Double DQN 추가 (2줄 수정) ↓ 3. Dueling Architecture 적용 ↓ 4. PER 추가 (가장 복잡) ↓ 5. 필요시 Multi-step, Noisy Networks 추가
Double DQN이 해결하려는 DQN의 문제점은 무엇인가요?