DQN으로 Atari 게임 정복하기 – 딥러닝 기반 강화학습 실전 적용

DQN의 등장 배경

지난 편에서 OpenAI Gym 환경 설정과 기본적인 Q-Learning 에이전트를 구현해봤습니다. 하지만 테이블 기반 Q-Learning은 상태 공간이 큰 환경에서는 사용할 수 없다는 치명적인 한계가 있었죠. Atari 게임처럼 화면 픽셀을 입력으로 받는 경우, 가능한 상태의 수가 천문학적으로 많아지기 때문입니다.

DeepMind는 2013년 이 문제를 딥러닝으로 해결한 DQN(Deep Q-Network)을 발표하며 강화학습 역사에 한 획을 그었습니다. 사람 수준 이상의 성능으로 49개 Atari 게임을 플레이한 이 알고리즘은 강화학습과 딥러닝의 완벽한 결합을 보여줬습니다.

DQN의 핵심 아이디어

1. Q-Table을 신경망으로 대체

DQN의 첫 번째 혁신은 Q-Table을 신경망으로 근사(approximation)한 것입니다:

import torch
import torch.nn as nn

class DQN(nn.Module):
    def __init__(self, input_shape, n_actions):
        super(DQN, self).__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(input_shape[0], 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 = self._get_conv_out(input_shape)
        self.fc = nn.Sequential(
            nn.Linear(conv_out_size, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions)
        )

    def _get_conv_out(self, shape):
        o = self.conv(torch.zeros(1, *shape))
        return int(np.prod(o.size()))

    def forward(self, x):
        conv_out = self.conv(x).view(x.size()[0], -1)
        return self.fc(conv_out)

이 네트워크는 84×84 크기의 4개 연속 프레임을 입력받아 각 행동의 Q-value를 출력합니다.

2. Experience Replay

신경망 학습의 안정성을 위해 리플레이 버퍼를 도입했습니다:

from collections import deque
import random

class ReplayBuffer:
    def __init__(self, capacity):
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        batch = random.sample(self.buffer, batch_size)
        state, action, reward, next_state, done = zip(*batch)
        return np.array(state), action, reward, np.array(next_state), done

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

왜 필요할까요?

문제 해결 방법
연속된 경험의 상관관계 랜덤 샘플링으로 독립성 확보
데이터 효율성 낮음 과거 경험 재사용
희귀한 경험 손실 버퍼에 저장해 반복 학습

3. Target Network

Q-value 업데이트의 불안정성을 해결하기 위해 별도의 타겟 네트워크를 유지합니다:

class DQNAgent:
    def __init__(self, state_shape, n_actions):
        self.policy_net = DQN(state_shape, n_actions).to(device)
        self.target_net = DQN(state_shape, n_actions).to(device)
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()

        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=1e-4)
        self.memory = ReplayBuffer(100000)
        self.steps_done = 0

    def update_target_network(self):
        self.target_net.load_state_dict(self.policy_net.state_dict())

Target network는 일정 주기(보통 10,000 step)마다만 업데이트되어, 학습 목표가 안정적으로 유지됩니다.

전체 학습 루프 구현

import gymnasium as gym
import ale_py

def train_dqn():
    env = gym.make('ALE/Breakout-v5', render_mode='rgb_array')
    env = gym.wrappers.AtariPreprocessing(env, frame_skip=4)
    env = gym.wrappers.FrameStack(env, 4)

    agent = DQNAgent(
        state_shape=(4, 84, 84),
        n_actions=env.action_space.n
    )

    epsilon = 1.0
    epsilon_decay = 0.999995
    epsilon_min = 0.1

    for episode in range(10000):
        state, _ = env.reset()
        episode_reward = 0

        while True:
            # Epsilon-greedy 행동 선택
            if random.random() < epsilon:
                action = env.action_space.sample()
            else:
                with torch.no_grad():
                    state_t = torch.FloatTensor(state).unsqueeze(0).to(device)
                    q_values = agent.policy_net(state_t)
                    action = q_values.max(1)[1].item()

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

            agent.memory.push(state, action, reward, next_state, done)
            state = next_state
            episode_reward += reward

            # 학습
            if len(agent.memory) > 10000:
                loss = agent.train_step(batch_size=32)

            # Target network 업데이트
            if agent.steps_done % 10000 == 0:
                agent.update_target_network()

            agent.steps_done += 1
            epsilon = max(epsilon_min, epsilon * epsilon_decay)

            if done:
                break

        if episode % 10 == 0:
            print(f"Episode {episode}, Reward: {episode_reward}, Epsilon: {epsilon:.3f}")

학습 단계 상세 구현

def train_step(self, batch_size):
    states, actions, rewards, next_states, dones = self.memory.sample(batch_size)

    states = torch.FloatTensor(states).to(device)
    actions = torch.LongTensor(actions).to(device)
    rewards = torch.FloatTensor(rewards).to(device)
    next_states = torch.FloatTensor(next_states).to(device)
    dones = torch.FloatTensor(dones).to(device)

    # 현재 Q-value
    current_q = self.policy_net(states).gather(1, actions.unsqueeze(1))

    # 타겟 Q-value (Double DQN)
    with torch.no_grad():
        next_actions = self.policy_net(next_states).max(1)[1]
        next_q = self.target_net(next_states).gather(1, next_actions.unsqueeze(1))
        target_q = rewards.unsqueeze(1) + (1 - dones.unsqueeze(1)) * 0.99 * next_q

    # Huber Loss
    loss = nn.SmoothL1Loss()(current_q, target_q)

    self.optimizer.zero_grad()
    loss.backward()
    torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), 10)
    self.optimizer.step()

    return loss.item()

DQN의 주요 개선 기법

Double DQN

기존 DQN은 Q-value를 과대평가하는 경향이 있습니다. Double DQN은 행동 선택과 평가를 분리합니다:

핵심: Policy network로 행동을 선택하고, Target network로 그 행동의 가치를 평가

Dueling DQN

Q-value를 State Value와 Advantage로 분해합니다:

class DuelingDQN(nn.Module):
    def __init__(self, input_shape, n_actions):
        super(DuelingDQN, self).__init__()
        self.conv = nn.Sequential(...)

        conv_out_size = self._get_conv_out(input_shape)

        # Value stream
        self.value_stream = nn.Sequential(
            nn.Linear(conv_out_size, 512),
            nn.ReLU(),
            nn.Linear(512, 1)
        )

        # Advantage stream
        self.advantage_stream = nn.Sequential(
            nn.Linear(conv_out_size, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions)
        )

    def forward(self, x):
        conv_out = self.conv(x).view(x.size()[0], -1)
        value = self.value_stream(conv_out)
        advantage = self.advantage_stream(conv_out)

        # Q(s,a) = V(s) + (A(s,a) - mean(A(s,a')))
        q_values = value + (advantage - advantage.mean(dim=1, keepdim=True))
        return q_values

실전 팁

하이퍼파라미터 설정:

파라미터 추천값 설명
Learning Rate 1e-4 Adam optimizer 기준
Batch Size 32 메모리 효율과 성능 균형
Replay Buffer 100K~1M 게임 복잡도에 따라 조정
Target Update 10K steps 너무 자주 업데이트하면 불안정
Gamma 0.99 장기 보상 중시
Epsilon Decay 0.999995 100만 step 기준

주의사항:
– 초반 10만 step은 학습이 거의 안 됩니다 (탐험 단계)
– GPU 필수 (CPU로는 학습에 수 주 소요)
– 프레임 스킵(4)과 스택(4) 필수
– Reward clipping (-1, 0, 1)으로 안정성 확보

마무리

DQN은 강화학습을 실용적인 수준으로 끌어올린 혁신적인 알고리즘입니다. Experience Replay와 Target Network라는 간단하지만 강력한 아이디어로 딥러닝의 불안정성을 극복했죠.

다음 편에서는 DQN의 한계인 연속 행동 공간을 다루는 PPO 알고리즘을 살펴보겠습니다. 로봇 제어나 레이싱 게임처럼 섬세한 조작이 필요한 환경에서 어떻게 강화학습을 적용하는지 알아봅시다!

강화학습을 이용해서 게임학습하기 시리즈 (2/5편)
강화학습을 이용해서 게임학습하기 시리즈 (2/5편)

이 글이 도움이 되셨나요?

Buy me a coffee

코멘트

“DQN으로 Atari 게임 정복하기 – 딥러닝 기반 강화학습 실전 적용” 에 하나의 답글

PPO 알고리즘으로 복잡한 게임 마스터하기 – 연속 행동 공간 다루기 – DevTips에 답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

TODAY 174 | TOTAL 174