멀티 에이전트 강화학습으로 대전 게임 구현하기 – MARL 실전 가이드

멀티 에이전트 강화학습이란?

지금까지 우리는 DQN과 PPO를 이용해 단일 에이전트가 게임 환경과 상호작용하며 학습하는 방법을 다뤘습니다. 하지만 실제 게임 환경에서는 여러 플레이어가 동시에 경쟁하거나 협력하는 경우가 많습니다. 이때 필요한 것이 바로 멀티 에이전트 강화학습(MARL, Multi-Agent Reinforcement Learning)입니다.

MARL은 여러 에이전트가 동일한 환경에서 동시에 학습하며, 각 에이전트의 행동이 다른 에이전트의 보상과 전략에 영향을 미칩니다. 1:1 대전 게임, 팀 기반 게임, 또는 경매 시스템 같은 복잡한 시나리오를 모델링할 수 있습니다.

MARL의 핵심 개념

비정상성(Non-Stationarity) 문제

단일 에이전트 강화학습에서는 환경이 고정되어 있지만, MARL에서는 다른 에이전트들이 계속 학습하면서 환경 자체가 변합니다. 에이전트 A가 새로운 전략을 학습하면, 에이전트 B의 입장에서는 환경이 바뀐 것과 같습니다.

학습 패러다임

MARL에는 크게 세 가지 학습 방식이 있습니다:

방식 설명 사용 예시
Independent Learning 각 에이전트가 독립적으로 학습 간단한 대전 게임
Centralized Training 중앙화된 정보로 학습, 분산 실행 협력 게임
Communication 에이전트 간 메시지 교환 팀 전략 게임

PettingZoo로 MARL 환경 구축하기

PettingZoo는 MARL을 위한 표준 라이브러리로, OpenAI Gym의 멀티 에이전트 버전입니다.

pip install pettingzoo[classic]
pip install stable-baselines3

간단한 대전 게임 환경 만들기

from pettingzoo.classic import tictactoe_v3
import numpy as np

# Tic-Tac-Toe 환경 생성
env = tictactoe_v3.env()
env.reset()

for agent in env.agent_iter():
    observation, reward, termination, truncation, info = env.last()

    if termination or truncation:
        action = None
    else:
        # 랜덤 행동 선택 (학습 전)
        action = env.action_space(agent).sample()

    env.step(action)

env.close()

Self-Play로 대전 AI 학습시키기

Self-Play는 에이전트가 자기 자신의 복사본과 대전하며 학습하는 방식입니다. AlphaGo가 사용한 핵심 기법이기도 합니다.

PPO 기반 Self-Play 구현

import torch
import torch.nn as nn
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv
from pettingzoo.classic import connect_four_v3

class SelfPlayWrapper:
    def __init__(self, env_fn):
        self.env = env_fn()
        self.agents = {}

    def train_agent(self, agent_name, timesteps=100000):
        """특정 에이전트를 Self-Play로 학습"""
        # 단일 에이전트 환경으로 변환
        single_env = self._make_single_env(agent_name)

        model = PPO(
            "MlpPolicy",
            single_env,
            learning_rate=3e-4,
            n_steps=2048,
            batch_size=64,
            n_epochs=10,
            verbose=1
        )

        model.learn(total_timesteps=timesteps)
        self.agents[agent_name] = model
        return model

    def _make_single_env(self, agent_name):
        """PettingZoo 환경을 SB3 호환 환경으로 변환"""
        def env_fn():
            return SingleAgentEnv(self.env, agent_name, self.agents)
        return DummyVecEnv([env_fn])

class SingleAgentEnv:
    """멀티 에이전트 환경을 단일 에이전트 관점으로 변환"""
    def __init__(self, pz_env, agent_name, opponent_agents):
        self.env = pz_env
        self.agent_name = agent_name
        self.opponent_agents = opponent_agents
        self.observation_space = pz_env.observation_space(agent_name)
        self.action_space = pz_env.action_space(agent_name)

    def reset(self):
        self.env.reset()
        # 자신의 차례까지 진행
        return self._wait_for_turn()

    def step(self, action):
        self.env.step(action)
        obs, reward, done, info = self._wait_for_turn()
        return obs, reward, done, info

    def _wait_for_turn(self):
        """상대 에이전트 행동 처리 후 자신의 관찰 반환"""
        for agent in self.env.agent_iter():
            obs, reward, term, trunc, info = self.env.last()

            if agent == self.agent_name:
                return obs, reward, term or trunc, info

            # 상대는 학습된 모델 또는 랜덤 선택
            if agent in self.opponent_agents:
                action, _ = self.opponent_agents[agent].predict(obs)
            else:
                action = self.env.action_space(agent).sample()

            self.env.step(action)

Independent Q-Learning 구현

각 에이전트가 독립적으로 Q-Learning을 수행하는 가장 기본적인 MARL 방식입니다.

import torch
import torch.nn as nn
import torch.optim as optim
from collections import defaultdict
import random

class MAQLearning:
    def __init__(self, env, lr=0.001, gamma=0.99, epsilon=1.0):
        self.env = env
        self.agents = env.possible_agents
        self.q_networks = {}
        self.optimizers = {}
        self.epsilon = epsilon
        self.gamma = gamma

        # 각 에이전트별 Q-Network 생성
        for agent in self.agents:
            obs_dim = env.observation_space(agent).shape[0]
            act_dim = env.action_space(agent).n
            self.q_networks[agent] = nn.Sequential(
                nn.Linear(obs_dim, 128),
                nn.ReLU(),
                nn.Linear(128, 64),
                nn.ReLU(),
                nn.Linear(64, act_dim)
            )
            self.optimizers[agent] = optim.Adam(
                self.q_networks[agent].parameters(), lr=lr
            )

    def select_action(self, agent, obs):
        """Epsilon-Greedy 행동 선택"""
        if random.random() < self.epsilon:
            return self.env.action_space(agent).sample()

        with torch.no_grad():
            obs_tensor = torch.FloatTensor(obs).unsqueeze(0)
            q_values = self.q_networks[agent](obs_tensor)
            return q_values.argmax().item()

    def train_episode(self):
        """한 에피소드 학습"""
        self.env.reset()
        episode_rewards = defaultdict(float)
        transitions = defaultdict(list)

        for agent in self.env.agent_iter():
            obs, reward, term, trunc, info = self.env.last()

            if term or trunc:
                action = None
            else:
                action = self.select_action(agent, obs)
                transitions[agent].append((obs, action, reward))

            self.env.step(action)
            episode_rewards[agent] += reward

        # 각 에이전트 업데이트
        for agent in self.agents:
            self._update_agent(agent, transitions[agent])

        # Epsilon decay
        self.epsilon = max(0.01, self.epsilon * 0.995)

        return episode_rewards

    def _update_agent(self, agent, transitions):
        """Q-Network 업데이트"""
        if len(transitions) == 0:
            return

        for i, (obs, action, reward) in enumerate(transitions):
            obs_tensor = torch.FloatTensor(obs).unsqueeze(0)
            q_values = self.q_networks[agent](obs_tensor)
            q_value = q_values[0, action]

            # 다음 상태 Q-value (에피소드 끝이면 0)
            if i < len(transitions) - 1:
                next_obs = transitions[i + 1][0]
                next_obs_tensor = torch.FloatTensor(next_obs).unsqueeze(0)
                with torch.no_grad():
                    next_q = self.q_networks[agent](next_obs_tensor).max()
                target = reward + self.gamma * next_q
            else:
                target = torch.FloatTensor([reward])

            loss = nn.MSELoss()(q_value, target)

            self.optimizers[agent].backward()
            self.optimizers[agent].step()
            self.optimizers[agent].zero_grad()

학습 및 평가

# 환경 생성
env = connect_four_v3.env()
ma_learner = MAQLearning(env)

# 학습
for episode in range(5000):
    rewards = ma_learner.train_episode()

    if episode % 100 == 0:
        avg_reward = np.mean(list(rewards.values()))
        print(f"Episode {episode}, Avg Reward: {avg_reward:.2f}, Epsilon: {ma_learner.epsilon:.3f}")

# 학습된 에이전트로 대전
env.reset()
for agent in env.agent_iter():
    obs, reward, term, trunc, info = env.last()

    if term or trunc:
        action = None
    else:
        with torch.no_grad():
            obs_tensor = torch.FloatTensor(obs).unsqueeze(0)
            action = ma_learner.q_networks[agent](obs_tensor).argmax().item()

    env.step(action)

MARL의 도전 과제와 해결책

1. 수렴 불안정성

문제: 모든 에이전트가 동시에 학습하면 환경이 계속 변해 수렴이 어렵습니다.

해결:
– 과거 버전의 에이전트를 상대로 학습 (Self-Play League)
– 학습률을 점진적으로 감소
– Experience Replay Buffer 공유

2. 신용 할당(Credit Assignment)

문제: 팀 게임에서 승리 시 어떤 에이전트의 기여도가 높은지 판단이 어렵습니다.

해결:
– Value Decomposition Networks (VDN)
– QMIX 알고리즘 사용

# QMIX 핵심 아이디어 (간소화)
class QMixer(nn.Module):
    def __init__(self, n_agents):
        super().__init__()
        self.n_agents = n_agents
        self.hyper_w1 = nn.Linear(state_dim, n_agents * 32)
        self.hyper_w2 = nn.Linear(state_dim, 32)

    def forward(self, agent_qs, state):
        """개별 Q-value를 결합하여 팀 Q-value 생성"""
        # 하이퍼네트워크로 믹싱 가중치 생성
        w1 = torch.abs(self.hyper_w1(state))
        w2 = torch.abs(self.hyper_w2(state))

        # 단조성 제약으로 개별 기여도 보장
        agent_qs = agent_qs.view(-1, 1, self.n_agents)
        hidden = torch.relu(torch.matmul(agent_qs, w1.view(-1, self.n_agents, 32)))
        q_total = torch.matmul(hidden, w2.view(-1, 32, 1))

        return q_total

마무리

이번 편에서는 멀티 에이전트 강화학습의 기초부터 Self-Play, Independent Q-Learning까지 실전 구현 방법을 다뤘습니다. MARL은 비정상성과 신용 할당 같은 고유한 도전 과제가 있지만, PettingZoo와 같은 라이브러리를 활용하면 생각보다 쉽게 시작할 수 있습니다.

다음 편에서는 커리큘럼 학습리워드 엔지니어링을 통해 게임 AI의 성능을 극대화하는 방법을 알아보겠습니다. 학습 난이도를 점진적으로 높이고, 보상 함수를 정교하게 설계해 에이전트가 더 빠르고 안정적으로 학습하도록 만들어봅시다!

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

이 글이 도움이 되셨나요?

Buy me a coffee

코멘트

“멀티 에이전트 강화학습으로 대전 게임 구현하기 – MARL 실전 가이드” 에 하나의 답글

  1. […] 강화학습을 이용해서 게임학습하기 시리즈 (5/5편) ← 이전: 멀티 에이전트 강화학습으로 대전 게임 구현하기 – MARL 실전… […]

커리큘럼 학습과 리워드 엔지니어링 – 게임 AI 성능 끌어올리기 – DevTips에 답글 남기기 응답 취소

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

TODAY 173 | TOTAL 173