PPO 알고리즘으로 복잡한 게임 마스터하기 – 연속 행동 공간 다루기

시작하며

지난 편에서는 DQN을 활용해 Atari 게임을 학습하는 방법을 다뤘습니다. DQN은 이산 행동 공간에서 강력하지만, 실제 게임 환경에서는 조이스틱의 미묘한 각도 조절이나 가속도 조절처럼 연속적인 행동 공간을 다뤄야 할 때가 많습니다. 이번 편에서는 이러한 문제를 해결하는 PPO(Proximal Policy Optimization) 알고리즘을 깊이 있게 살펴보겠습니다.

PPO가 필요한 이유

연속 행동 공간의 도전 과제

DQN은 Q-value를 계산해 최적의 행동을 선택하는 방식인데, 행동이 무한히 많은 연속 공간에서는 모든 행동의 Q-value를 계산할 수 없습니다. 예를 들어:

  • 레이싱 게임: 핸들 각도 [-1.0, 1.0], 가속 페달 [0.0, 1.0]
  • 로봇 제어: 각 관절의 토크 값
  • FPS 게임: 시점 이동의 정밀한 속도 조절

이런 환경에서는 정책 기반(policy-based) 방법이 필요합니다.

Policy Gradient의 불안정성

전통적인 Policy Gradient 방법(REINFORCE, A3C 등)은 학습이 매우 불안정합니다:

# 기존 Policy Gradient의 문제
# 너무 큰 업데이트 → 성능 급락 → 복구 불가
for episode in range(1000):
    loss = -log_prob * advantage
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()  # 스텝 크기 제어 없음!

PPO는 이 문제를 clipped objective로 해결합니다.

PPO 핵심 메커니즘

1. Importance Sampling Ratio

PPO는 새로운 정책과 이전 정책의 비율을 계산합니다:

import torch
import torch.nn as nn

class PPOAgent(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        self.actor = nn.Sequential(
            nn.Linear(state_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 256),
            nn.ReLU(),
            nn.Linear(256, action_dim * 2)  # mean & std
        )
        self.critic = nn.Sequential(
            nn.Linear(state_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 256),
            nn.ReLU(),
            nn.Linear(256, 1)
        )

    def get_action(self, state):
        output = self.actor(state)
        mean, log_std = output.chunk(2, dim=-1)
        std = log_std.exp()

        # 정규 분포에서 샘플링
        dist = torch.distributions.Normal(mean, std)
        action = dist.sample()
        log_prob = dist.log_prob(action).sum(dim=-1)

        return action, log_prob

    def evaluate(self, state, action):
        output = self.actor(state)
        mean, log_std = output.chunk(2, dim=-1)
        std = log_std.exp()

        dist = torch.distributions.Normal(mean, std)
        log_prob = dist.log_prob(action).sum(dim=-1)
        entropy = dist.entropy().sum(dim=-1)

        value = self.critic(state).squeeze(-1)
        return log_prob, value, entropy

2. Clipped Surrogate Objective

PPO의 핵심은 정책 업데이트를 제한하는 것입니다:

def ppo_loss(old_log_probs, new_log_probs, advantages, clip_epsilon=0.2):
    # Importance sampling ratio
    ratio = (new_log_probs - old_log_probs).exp()

    # 두 가지 목적 함수 중 작은 값 선택
    obj1 = ratio * advantages
    obj2 = torch.clamp(ratio, 1-clip_epsilon, 1+clip_epsilon) * advantages

    policy_loss = -torch.min(obj1, obj2).mean()
    return policy_loss

이 방식은 ratio가 [0.8, 1.2] 범위를 벗어나면 업데이트를 차단합니다.

3. GAE(Generalized Advantage Estimation)

Advantage를 정확하게 추정하는 것이 성능의 핵심입니다:

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95):
    advantages = []
    gae = 0

    for t in reversed(range(len(rewards))):
        if t == len(rewards) - 1:
            next_value = 0
        else:
            next_value = values[t + 1]

        # TD error
        delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t]

        # GAE 계산
        gae = delta + gamma * lam * (1 - dones[t]) * gae
        advantages.insert(0, gae)

    return torch.tensor(advantages)

실전 구현: LunarLanderContinuous

환경 설정

import gymnasium as gym
import numpy as np

env = gym.make('LunarLanderContinuous-v2')
state_dim = env.observation_space.shape[0]  # 8
action_dim = env.action_space.shape[0]      # 2 (main engine, side engine)

agent = PPOAgent(state_dim, action_dim)
optimizer = torch.optim.Adam(agent.parameters(), lr=3e-4)

학습 루프

def train_ppo(agent, env, epochs=500, steps_per_epoch=4000, batch_size=64):
    for epoch in range(epochs):
        # 데이터 수집
        states, actions, rewards, log_probs, values, dones = [], [], [], [], [], []
        state, _ = env.reset()

        for step in range(steps_per_epoch):
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            action, log_prob = agent.get_action(state_tensor)
            value = agent.critic(state_tensor).squeeze(-1)

            next_state, reward, terminated, truncated, _ = env.step(
                action.detach().numpy()[0]
            )
            done = terminated or truncated

            states.append(state)
            actions.append(action.detach())
            rewards.append(reward)
            log_probs.append(log_prob.detach())
            values.append(value.detach())
            dones.append(done)

            state = next_state
            if done:
                state, _ = env.reset()

        # Advantage 계산
        advantages = compute_gae(rewards, values, dones)
        returns = advantages + torch.stack(values)

        # 정규화
        advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

        # PPO 업데이트 (여러 에포크)
        for _ in range(10):
            indices = np.random.permutation(len(states))
            for start in range(0, len(states), batch_size):
                end = start + batch_size
                batch_idx = indices[start:end]

                batch_states = torch.FloatTensor(np.array(states)[batch_idx])
                batch_actions = torch.stack([actions[i] for i in batch_idx])
                batch_old_log_probs = torch.stack([log_probs[i] for i in batch_idx])
                batch_advantages = advantages[batch_idx]
                batch_returns = returns[batch_idx]

                # Forward pass
                new_log_probs, new_values, entropy = agent.evaluate(
                    batch_states, batch_actions
                )

                # Loss 계산
                policy_loss = ppo_loss(
                    batch_old_log_probs, new_log_probs, batch_advantages
                )
                value_loss = nn.MSELoss()(new_values, batch_returns)
                entropy_loss = -entropy.mean()

                loss = policy_loss + 0.5 * value_loss + 0.01 * entropy_loss

                optimizer.zero_grad()
                loss.backward()
                nn.utils.clip_grad_norm_(agent.parameters(), 0.5)
                optimizer.step()

        print(f"Epoch {epoch}: Reward {np.sum(rewards):.2f}")

하이퍼파라미터 튜닝 가이드

파라미터 추천 범위 역할
clip_epsilon 0.1~0.3 정책 변화 제한 폭
gamma 0.95~0.99 미래 보상 할인율
lam 0.9~0.98 GAE 가중치
learning_rate 1e-4~3e-4 학습 속도
entropy_coef 0.001~0.01 탐험 장려

디버깅 팁

# 학습 중 모니터링
if epoch % 10 == 0:
    ratio = (new_log_probs - batch_old_log_probs).exp()
    print(f"Policy Ratio: {ratio.mean():.3f} ± {ratio.std():.3f}")
    print(f"Value Loss: {value_loss:.3f}")
    print(f"Entropy: {entropy.mean():.3f}")

    # ratio가 [0.5, 2.0] 범위를 벗어나면 문제
    if ratio.max() > 3.0:
        print("⚠️ Warning: Policy changing too fast!")

성능 최적화 전략

1. Vectorized Environments

from stable_baselines3.common.vec_env import SubprocVecEnv

# 병렬 환경 실행
env = SubprocVecEnv([lambda: gym.make('LunarLanderContinuous-v2') 
                     for _ in range(8)])

2. Learning Rate Scheduling

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=epochs, eta_min=1e-5
)

3. Early Stopping

if np.mean(last_100_rewards) > 200:  # 목표 달성
    print("Solved!")
    break

마무리

이번 편에서는 PPO 알고리즘의 핵심 메커니즘과 연속 행동 공간을 다루는 실전 구현을 살펴봤습니다. PPO는 안정성샘플 효율성의 균형을 맞춘 알고리즘으로, 현업에서 가장 널리 사용됩니다.

다음 편에서는 여러 에이전트가 동시에 학습하는 멀티 에이전트 강화학습(MARL)을 다루며, 대전 게임 AI 구현 방법을 알아보겠습니다. 협력과 경쟁이 공존하는 복잡한 환경에서 어떻게 학습이 이뤄지는지 기대해주세요!

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

이 글이 도움이 되셨나요?

Buy me a coffee

코멘트

“PPO 알고리즘으로 복잡한 게임 마스터하기 – 연속 행동 공간 다루기” 에 하나의 답글

  1. […] 이용해서 게임학습하기 시리즈 (4/5편) ← 이전: PPO 알고리즘으로 복잡한 게임 마스터하기 – 연속 행동 공간 …다음 편 준비 […]

답글 남기기

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

TODAY 136 | TOTAL 136