본문 바로가기

DQN (Deep Q-Network) – Gym으로 CartPole 풀어보기

@eunyoung-study2026. 3. 12. 17:43

1. Gym(Gymnasium)이란?

Gym(OpenAI Gym, 현재는 Gymnasium) 은 강화학습 알고리즘을 실험하고 개발할 수 있도록
다양한 환경(environment)을 제공하는 파이썬 라이브러리입니다.

강화학습에서는 항상 이런 패턴으로 생각합니다.

에이전트 (Agent) ↔ 환경 (Environment)
            ↑ 행동(Action)
            ↓ 보상(Reward), 다음 상태(State)

Gym은 이 “환경 쪽”을 대신 구현해 주는 도구입니다.

  • CartPole, MountainCar, Pendulum 같은 클래식 제어 환경
  • Atari 게임
  • Box2D 기반 물리 환경 등

연구자나 개발자는 Gym을 통해 표준화된 환경에서 에이전트가 행동을 선택하고 보상을 받는 과정을 손쉽게 구현할 수 있고,
알고리즘 간 성능을 비교하기에도 좋습니다.


2. 이번에 다룰 예제 – CartPole + DQN

노트북에서는 DQN(Deep Q-Network) 으로 CartPole-v1 환경을 학습시키는 예제를 다룹니다.

  • 환경: CartPole-v1
    • 카트 위에 막대가 세워져 있고, 카트를 좌우로 움직여 막대가 쓰러지지 않게 유지하는 문제
  • 목표: 에이전트가 현재 상태(카트 위치, 속도, 막대 각도, 각속도)를 보고 왼쪽/오른쪽 행동을 선택해 막대를 최대한 오래 세워두기
  • 알고리즘: DQN
    • 신경망으로 Q(s, a) 를 근사하는 Q-learning

전체 구조는 다음과 같습니다.

환경: CartPole-v1
상태: [카트 위치, 카트 속도, 막대 각도, 막대 각속도] (4차원 실수 벡터)
행동: 0(왼쪽), 1(오른쪽)

에이전트: DQN
- Q네트워크(q): 현재 Q값 예측
- 타깃 네트워크(q_target): 안정적인 학습을 위해 사용
- 리플레이 버퍼(Replay Buffer): 경험 (s, a, r, s') 저장 후 샘플링

3. CartPole 환경과 DQN 구성 요소

3.1 라이브러리와 하이퍼파라미터

import gymnasium as gym
import collections
import random

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

# Hyperparameters
learning_rate = 0.0005
gamma         = 0.98
buffer_limit  = 50000
batch_size    = 32
  • gamma: 할인율 (미래 보상을 얼마나 중요하게 볼지)
  • buffer_limit: 리플레이 버퍼에 최대 몇 개의 transition을 저장할지
  • batch_size: 한 번 학습할 때 몇 개의 경험을 샘플링할지

3.2 Replay Buffer – 경험을 모아두는 창고

DQN에서는 경험 하나를 곧바로 학습에 쓰지 않고, 버퍼에 쌓았다가 랜덤하게 꺼내 학습합니다.
이를 경험 재현(Experience Replay) 이라고 합니다.

class ReplayBuffer():
    def __init__(self):
        self.buffer = collections.deque(maxlen=buffer_limit)

    def put(self, transition):
        self.buffer.append(transition)

    def sample(self, n):
        mini_batch = random.sample(self.buffer, n)
        s_lst, a_lst, r_lst, s_prime_lst, done_mask_lst = [], [], [], [], []

        for transition in mini_batch:
            s, a, r, s_prime, done_mask = transition
            s_lst.append(s)
            a_lst.append([a])
            r_lst.append([r])
            s_prime_lst.append(s_prime)
            done_mask_lst.append([done_mask])

        return (
            torch.tensor(s_lst, dtype=torch.float),
            torch.tensor(a_lst),
            torch.tensor(r_lst),
            torch.tensor(s_prime_lst, dtype=torch.float),
            torch.tensor(done_mask_lst),
        )

    def size(self):
        return len(self.buffer)
  • put()으로 (s, a, r, s′, done_mask) 형태의 경험을 저장
  • sample(n)에서 랜덤 미니배치를 뽑아 PyTorch 텐서로 변환해 반환
  • done_mask는 에피소드가 끝났는지 여부를 0/1로 표현해, 타깃 계산에 사용합니다.

경험을 섞어서 학습하면:

  • 샘플 간 상관관계를 줄여 안정적인 학습이 가능하고,
  • 같은 경험을 여러 번 재사용해 샘플 효율을 높일 수 있습니다.

3.3 Q 네트워크 – 상태를 Q값으로 바꾸는 신경망

CartPole 상태는 4차원, 행동은 2개(왼쪽/오른쪽)이므로,
입력 4 → 은닉층들 → 출력 2 형태의 네트워크를 사용합니다.

class Qnet(nn.Module):
    def __init__(self):
        super(Qnet, self).__init__()
        # 입력: 상태(4차원) → 은닉층 128 → 은닉층 128 → 출력: 행동별 Q값(2차원)
        self.fc1 = nn.Linear(4, 128)
        self.fc2 = nn.Linear(128, 128)
        self.fc3 = nn.Linear(128, 2)  # 0: 왼쪽, 1: 오른쪽

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

    def sample_action(self, obs, epsilon):
        out = self.forward(obs)  # 행동별 Q값
        coin = random.random()
        if coin < epsilon:
            # ε 비율로 랜덤 행동 (exploration)
            return random.randint(0, 1)
        else:
            # 나머지는 Q값이 가장 큰 행동 선택 (exploitation)
            return out.argmax().item()

여기서도 ε-greedy 전략을 사용합니다.

  • 초반에는 ε가 상대적으로 커서 탐험(exploration) 을 많이 하고,
  • 학습이 진행될수록 ε를 줄여 학습된 정책을 더 많이 활용(exploitation) 합니다.

4. DQN 학습 루프 – train 함수

4.1 한 번의 학습 스텝

def train(q, q_target, memory, optimizer):
    for i in range(10):
        # 버퍼에서 batch_size 만큼 경험을 랜덤 샘플링
        s, a, r, s_prime, done_mask = memory.sample(batch_size)

        q_out = q(s)               # (batch_size, 2)  각 상태에서의 행동별 Q값
        q_a = q_out.gather(1, a)   # 실제로 취한 행동 a에 해당하는 Q값만 추출

        # 타깃 네트워크로 다음 상태의 최대 Q값 계산
        max_q_prime = q_target(s_prime).max(1)[0].unsqueeze(1)
        target = r + gamma * max_q_prime * done_mask

        # Huber Loss (smooth_l1_loss) 사용 – DQN에서 자주 쓰는 손실
        loss = F.smooth_l1_loss(q_a, target)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
  • q: 현재 학습 중인 Q 네트워크
  • q_target: 타깃 네트워크 (일정 주기마다만 업데이트해, 학습을 안정화)
  • done_mask: 에피소드가 끝난 상태에서는 max_q_prime를 0으로 만들어, 더 이상 미래 보상을 추가하지 않도록 합니다.

핵심은 여전히 Q-learning 수식입니다.

target = r + γ maxₐ′ Q_target(s′, a′)
loss = Huber(Q(s,a), target)

5. 전체 학습 과정 – CartPole DQN main 루프

def main():
    # CartPole 환경 생성 – 막대가 쓰러지지 않도록 카트를 좌우로 움직이는 문제
    env = gym.make('CartPole-v1')
    q = Qnet()          # 현재 Q 네트워크
    q_target = Qnet()   # 타깃 네트워크
    q_target.load_state_dict(q.state_dict())
    memory = ReplayBuffer()

    print_interval = 20
    score = 0.0
    optimizer = optim.Adam(q.parameters(), lr=learning_rate)

    for n_epi in range(10000):
        # epsilon 값을 점점 줄임 – 초반에는 exploration을 더 많이, 후반에는 정책 활용
        epsilon = max(0.01, 0.08 - 0.01 * (n_epi / 200))  # 8% → 1% 선형 감소

        s, info = env.reset()
        done = False

        while not done:
            a = q.sample_action(torch.from_numpy(s).float(), epsilon)
            s_prime, r, done, truncated, info = env.step(a)

            done_mask = 0.0 if done else 1.0
            # 보상을 100으로 나눠 스케일 조정
            memory.put((s, a, r / 100.0, s_prime, done_mask))
            s = s_prime
            score += r

            if done:
                break

        # 버퍼에 충분한 데이터가 쌓이면 학습 시작
        if memory.size() > 2000:
            train(q, q_target, memory, optimizer)

        # 일정 에피소드마다 타깃 네트워크를 현재 네트워크로 업데이트
        if n_epi % print_interval == 0 and n_epi != 0:
            q_target.load_state_dict(q.state_dict())
            print(\"n_episode :{}, score : {:.1f}, n_buffer : {}, eps : {:.1f}%\".format(
                n_epi, score / print_interval, memory.size(), epsilon * 100))
            score = 0.0

    env.close()

5.1 학습 로그 해석

실행하면 대략 이런 로그들이 출력됩니다.

n_episode :  20, score :  19.2, n_buffer :   385, eps : 7.9%
n_episode : 100, score :  17.8, n_buffer :  1849, eps : 7.5%
n_episode : 200, score :  59.6, n_buffer :  6063, eps : 7.0%
n_episode : 300, score : 175.5, n_buffer : 16865, eps : 6.5%
n_episode : 340, score : 204.3, n_buffer : 24500, eps : 6.3%
...
  • 초반(20, 100 epi)에는 평균 score(막대를 유지한 스텝 수) 가 20 근처로 낮습니다.
  • 학습이 진행될수록 score가 50, 100, 200… 으로 올라가며,
    에이전트가 점점 막대를 오래 유지하는 정책을 배우고 있다는 것을 보여줍니다.
  • n_buffer는 리플레이 버퍼에 쌓인 경험 개수, eps는 현재 ε(탐험 비율)입니다.
더보기

[ 오늘의 정리 ]

– Q-learning + Gym으로 실전 강화학습 시작하기

  • Gym(Gymnasium) 은 강화학습 실험용 표준 환경을 제공하는 라이브러리로,
    CartPole 같은 클래식 문제부터 Atari 게임까지 다양한 환경을 지원합니다.
  • 이번 예제에서는 DQN을 이용해 CartPole-v1 환경에서 막대가 쓰러지지 않도록
    카트를 좌우로 움직이는 정책을 학습했습니다.
  • 핵심 구성 요소는:
    • Q 네트워크(Qnet): 상태 → 행동별 Q값
    • 리플레이 버퍼(ReplayBuffer): 경험을 모아 샘플링
    • 타깃 네트워크(q_target): 안정적인 학습을 위해 천천히 업데이트
    • ε-greedy 탐험: 초반에는 더 많은 랜덤 행동, 후반에는 정책 활용
  • 이런 패턴은 CartPole뿐 아니라 다른 Gym 환경, 더 복잡한 Deep RL 문제로도 그대로 확장할 수 있는 기본 골격입니다.
eunyoung-study
@eunyoung-study :: 은영의 이해 노트

개념을 이해하고, 논문을 풀어보고, 코드로 확인하는 기록 ! 오늘도 파이팅 😉

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차