LabHub

블로그

[심층 강화학습] 03. PyTorch 딥러닝 기초: 텐서부터 신경망까지

한국어English日本語

PyTorch 소개

PyTorch는 Facebook(현 Meta)에서 개발한 딥러닝 프레임워크입니다. 동적 계산 그래프, 직관적인 API, 강력한 GPU 지원이 특징이며, 강화학습 연구에서 가장 널리 사용되는 프레임워크 중 하나입니다.

설치

pip install torch torchvision

텐서 (Tensor)

텐서는 PyTorch의 기본 데이터 구조로, NumPy 배열과 비슷하지만 GPU 연산과 자동 미분을 지원합니다.

텐서 생성

import torch
import numpy as np

# 다양한 방법으로 텐서 생성
# 리스트로부터
t1 = torch.tensor([1, 2, 3])
print(f"리스트로부터: {t1}")

# 특정 값으로 초기화
t_zeros = torch.zeros(3, 4)
t_ones = torch.ones(2, 3)
t_rand = torch.rand(2, 3)  # 0~1 균일 분포
t_randn = torch.randn(2, 3)  # 표준 정규 분포

print(f"영 텐서:\n{t_zeros}")
print(f"랜덤 텐서:\n{t_rand}")

# NumPy 배열로부터
np_array = np.array([[1.0, 2.0], [3.0, 4.0]])
t_from_np = torch.from_numpy(np_array)
print(f"NumPy로부터: {t_from_np}")

# 텐서를 NumPy로 변환
back_to_np = t_from_np.numpy()
print(f"다시 NumPy로: {back_to_np}")

텐서 속성

t = torch.randn(3, 4, 5)

print(f"형상 (shape): {t.shape}")
print(f"데이터 타입 (dtype): {t.dtype}")
print(f"장치 (device): {t.device}")
print(f"차원 수 (ndim): {t.ndim}")
print(f"전체 원소 수 (numel): {t.numel()}")

텐서 연산

a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
b = torch.tensor([[5.0, 6.0], [7.0, 8.0]])

# 기본 연산
print(f"덧셈: {a + b}")
print(f"곱셈 (원소별): {a * b}")
print(f"행렬 곱: {a @ b}")
print(f"행렬 곱 (동일): {torch.matmul(a, b)}")

# 형상 변환
t = torch.arange(12)
print(f"원본: {t.shape}")

t_reshaped = t.reshape(3, 4)
print(f"reshape(3,4): {t_reshaped.shape}")

t_viewed = t.view(2, 6)
print(f"view(2,6): {t_viewed.shape}")

# 차원 추가/제거
t = torch.randn(3, 4)
t_unsqueeze = t.unsqueeze(0)  # 배치 차원 추가
print(f"unsqueeze(0): {t_unsqueeze.shape}")  # (1, 3, 4)

t_squeeze = t_unsqueeze.squeeze(0)  # 크기 1인 차원 제거
print(f"squeeze(0): {t_squeeze.shape}")  # (3, 4)

# 인덱싱과 슬라이싱
t = torch.randn(4, 5)
print(f"첫 번째 행: {t[0]}")
print(f"마지막 열: {t[:, -1]}")
print(f"2x3 부분: {t[:2, :3].shape}")

GPU 연산

PyTorch에서 GPU를 사용하면 대규모 텐서 연산을 병렬로 처리할 수 있습니다.

# GPU 사용 가능 여부 확인
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"사용 장치: {device}")

# MPS (Apple Silicon) 지원 확인
if torch.backends.mps.is_available():
    device = torch.device("mps")
    print("Apple Silicon GPU 사용")

# 텐서를 GPU로 이동
t = torch.randn(1000, 1000)
t_gpu = t.to(device)
print(f"텐서 장치: {t_gpu.device}")

# GPU에서 연산
a = torch.randn(1000, 1000, device=device)
b = torch.randn(1000, 1000, device=device)
c = a @ b  # GPU에서 행렬 곱 수행

# 결과를 CPU로 다시 이동
c_cpu = c.cpu()

그래디언트와 자동 미분 (Autograd)

PyTorch의 가장 강력한 기능 중 하나는 자동 미분입니다. requires_grad=True로 설정된 텐서의 모든 연산을 추적하여 자동으로 그래디언트를 계산합니다.

기본 자동 미분

# requires_grad=True로 그래디언트 추적 활성화
x = torch.tensor([2.0, 3.0], requires_grad=True)

# 순방향 계산: y = x^2 + 3x
y = x ** 2 + 3 * x
print(f"y = {y}")

# 역전파: dy/dx = 2x + 3
z = y.sum()
z.backward()

print(f"x의 그래디언트: {x.grad}")
# x=2일 때 dy/dx = 2*2+3 = 7
# x=3일 때 dy/dx = 2*3+3 = 9
# 출력: tensor([7., 9.])

그래디언트 계산 제어

# 그래디언트 추적 비활성화 (추론 시 사용)
x = torch.randn(3, requires_grad=True)

# 방법 1: torch.no_grad()
with torch.no_grad():
    y = x * 2
    print(f"requires_grad: {y.requires_grad}")  # False

# 방법 2: detach()
z = x.detach()
print(f"detach 후 requires_grad: {z.requires_grad}")  # False

# 그래디언트 초기화 (반복 학습 시 중요)
x = torch.tensor([1.0], requires_grad=True)

for i in range(3):
    y = x ** 2
    y.backward()
    print(f"반복 {i}: grad = {x.grad}")
    x.grad.zero_()  # 그래디언트 초기화 필수

간단한 최적화 예시

# 그래디언트 하강법으로 f(x) = (x - 3)^2 최소화
x = torch.tensor([0.0], requires_grad=True)
learning_rate = 0.1

for step in range(20):
    # 순방향: 손실 계산
    loss = (x - 3) ** 2

    # 역방향: 그래디언트 계산
    loss.backward()

    # 파라미터 업데이트 (그래디언트 추적 없이)
    with torch.no_grad():
        x -= learning_rate * x.grad

    # 그래디언트 초기화
    x.grad.zero_()

    if step % 5 == 0:
        print(f"스텝 {step}: x = {x.item():.4f}, loss = {loss.item():.6f}")

print(f"최종 x = {x.item():.4f} (목표: 3.0)")

신경망 구성 요소 (nn 모듈)

PyTorch의 torch.nn 모듈은 신경망 구성에 필요한 모든 빌딩 블록을 제공합니다.

기본 신경망 구성

import torch
import torch.nn as nn

class SimpleNetwork(nn.Module):
    """간단한 피드포워드 신경망"""
    def __init__(self, input_size, hidden_size, output_size):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(input_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, output_size),
        )

    def forward(self, x):
        return self.network(x)

# 모델 생성 및 테스트
model = SimpleNetwork(input_size=4, hidden_size=128, output_size=2)
print(model)

# 가짜 입력으로 순방향 전파
x = torch.randn(32, 4)  # 배치 크기 32, 입력 차원 4
output = model(x)
print(f"출력 형상: {output.shape}")  # (32, 2)

# 파라미터 수 확인
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"전체 파라미터: {total_params:,}")
print(f"학습 가능 파라미터: {trainable_params:,}")

주요 레이어

# 선형 레이어 (Fully Connected)
linear = nn.Linear(in_features=10, out_features=5)

# 합성곱 레이어
conv = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1)

# 배치 정규화
bn = nn.BatchNorm2d(num_features=32)

# 드롭아웃
dropout = nn.Dropout(p=0.5)

# 활성화 함수
relu = nn.ReLU()
tanh = nn.Tanh()
sigmoid = nn.Sigmoid()
softmax = nn.Softmax(dim=-1)

커스텀 레이어

자신만의 레이어를 정의할 수 있습니다. nn.Module을 상속하고 forward 메서드를 구현하면 됩니다.

class NoisyLinear(nn.Module):
    """노이즈가 추가된 선형 레이어 (탐색 강화 목적)"""
    def __init__(self, in_features, out_features, noise_std=0.1):
        super().__init__()
        self.linear = nn.Linear(in_features, out_features)
        self.noise_std = noise_std

    def forward(self, x):
        if self.training:
            noise = torch.randn_like(self.linear.weight) * self.noise_std
            weight = self.linear.weight + noise
            return x @ weight.t() + self.linear.bias
        return self.linear(x)

class DuelingHead(nn.Module):
    """Dueling DQN 구조의 출력 헤드"""
    def __init__(self, input_size, n_actions):
        super().__init__()
        self.value_stream = nn.Sequential(
            nn.Linear(input_size, 128),
            nn.ReLU(),
            nn.Linear(128, 1),
        )
        self.advantage_stream = nn.Sequential(
            nn.Linear(input_size, 128),
            nn.ReLU(),
            nn.Linear(128, n_actions),
        )

    def forward(self, x):
        value = self.value_stream(x)
        advantage = self.advantage_stream(x)
        # Q = V + (A - mean(A))
        q_values = value + advantage - advantage.mean(dim=-1, keepdim=True)
        return q_values

# 사용 예시
head = DuelingHead(input_size=256, n_actions=4)
features = torch.randn(16, 256)
q_values = head(features)
print(f"Q 값 형상: {q_values.shape}")  # (16, 4)

손실 함수

강화학습에서 자주 사용되는 손실 함수들입니다.

# MSE 손실 (가치 함수 학습)
mse_loss = nn.MSELoss()
predicted = torch.tensor([2.5, 0.0, -1.0])
target = torch.tensor([3.0, -0.5, -1.0])
loss = mse_loss(predicted, target)
print(f"MSE 손실: {loss.item():.4f}")

# Huber 손실 (DQN에서 많이 사용, MSE보다 이상치에 강건)
huber_loss = nn.SmoothL1Loss()
loss = huber_loss(predicted, target)
print(f"Huber 손실: {loss.item():.4f}")

# Cross-Entropy 손실 (정책 학습)
ce_loss = nn.CrossEntropyLoss()
logits = torch.tensor([[2.0, 1.0, 0.1]])  # 3개 행동의 로짓
target_action = torch.tensor([0])  # 정답 행동
loss = ce_loss(logits, target_action)
print(f"Cross-Entropy 손실: {loss.item():.4f}")

옵티마이저

model = SimpleNetwork(4, 128, 2)

# SGD
optimizer_sgd = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

# Adam (가장 많이 사용)
optimizer_adam = torch.optim.Adam(model.parameters(), lr=0.001)

# RMSprop (DQN 원논문에서 사용)
optimizer_rms = torch.optim.RMSprop(model.parameters(), lr=0.00025, alpha=0.95)

# 학습 루프 기본 구조
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

for epoch in range(100):
    # 순방향 전파
    x = torch.randn(32, 4)
    target = torch.randn(32, 2)
    prediction = model(x)

    # 손실 계산
    loss = nn.MSELoss()(prediction, target)

    # 역방향 전파 및 파라미터 업데이트
    optimizer.zero_grad()  # 그래디언트 초기화
    loss.backward()        # 그래디언트 계산
    optimizer.step()       # 파라미터 업데이트

    if epoch % 20 == 0:
        print(f"에폭 {epoch}: 손실 = {loss.item():.4f}")

학습률 스케줄러

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# StepLR: 일정 에폭마다 학습률 감소
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)

# ExponentialLR: 매 에폭 지수적 감소
scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.99)

# 사용 방법
for epoch in range(100):
    # ... 학습 코드 ...
    scheduler.step()
    current_lr = optimizer.param_groups[0]['lr']

CNN으로 이미지 처리

Atari 게임 등 이미지 기반 강화학습에서는 합성곱 신경망(CNN)이 핵심입니다.

class AtariCNN(nn.Module):
    """Atari 게임용 CNN (DQN 논문 기반)"""
    def __init__(self, input_channels, n_actions):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(input_channels, 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(),
        )
        # 84x84 입력 기준 conv 출력 크기 계산
        conv_output_size = self._get_conv_output_size(input_channels)

        self.fc = nn.Sequential(
            nn.Linear(conv_output_size, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions),
        )

    def _get_conv_output_size(self, input_channels):
        dummy = torch.zeros(1, input_channels, 84, 84)
        output = self.conv(dummy)
        return int(output.view(1, -1).shape[1])

    def forward(self, x):
        # 입력: (batch, channels, 84, 84)
        # 0-255 범위를 0-1로 정규화
        x = x.float() / 255.0
        conv_out = self.conv(x)
        flat = conv_out.view(conv_out.size(0), -1)
        return self.fc(flat)

# 테스트
model = AtariCNN(input_channels=4, n_actions=6)
dummy_input = torch.randint(0, 256, (8, 4, 84, 84), dtype=torch.uint8)
q_values = model(dummy_input)
print(f"Q값 형상: {q_values.shape}")  # (8, 6)

TensorBoard 모니터링

학습 과정을 시각적으로 모니터링하는 것은 매우 중요합니다.

from torch.utils.tensorboard import SummaryWriter
import time

# TensorBoard 기록기 생성
writer = SummaryWriter(log_dir="runs/rl_experiment")

# 스칼라 값 기록 (보상, 손실 등)
for step in range(1000):
    fake_loss = 1.0 / (step + 1)
    fake_reward = step * 0.1
    fake_epsilon = max(0.01, 1.0 - step * 0.001)

    writer.add_scalar("training/loss", fake_loss, step)
    writer.add_scalar("training/reward", fake_reward, step)
    writer.add_scalar("training/epsilon", fake_epsilon, step)

# 여러 값을 한 그래프에
writer.add_scalars("comparison", {
    "train_loss": 0.5,
    "val_loss": 0.7,
}, global_step=0)

# 모델 구조 기록
model = SimpleNetwork(4, 128, 2)
dummy_input = torch.randn(1, 4)
writer.add_graph(model, dummy_input)

# 히스토그램 (가중치 분포 확인)
for name, param in model.named_parameters():
    writer.add_histogram(name, param.data, global_step=0)

writer.close()

TensorBoard를 실행하려면 터미널에서 다음 명령을 입력합니다.

tensorboard --logdir=runs

GAN으로 Atari 이미지 생성

GAN(Generative Adversarial Network)의 기본 개념을 이해하기 위해, Atari 게임 화면을 생성하는 간단한 GAN을 구현해 봅니다.

import torch
import torch.nn as nn

class Generator(nn.Module):
    """생성자: 랜덤 노이즈로부터 이미지 생성"""
    def __init__(self, latent_dim=100, img_channels=1, img_size=64):
        super().__init__()
        self.img_size = img_size

        self.model = nn.Sequential(
            # 입력: (batch, latent_dim)
            nn.Linear(latent_dim, 256),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(256),

            nn.Linear(256, 512),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(512),

            nn.Linear(512, 1024),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(1024),

            nn.Linear(1024, img_channels * img_size * img_size),
            nn.Tanh(),
        )
        self.img_channels = img_channels

    def forward(self, z):
        img = self.model(z)
        return img.view(-1, self.img_channels, self.img_size, self.img_size)

class Discriminator(nn.Module):
    """판별자: 진짜/가짜 이미지 구별"""
    def __init__(self, img_channels=1, img_size=64):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(img_channels * img_size * img_size, 512),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),

            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),

            nn.Linear(256, 1),
            nn.Sigmoid(),
        )

    def forward(self, img):
        flat = img.view(img.size(0), -1)
        return self.model(flat)

GAN 학습 루프

def train_gan(n_epochs=50, batch_size=64, latent_dim=100):
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    generator = Generator(latent_dim=latent_dim).to(device)
    discriminator = Discriminator().to(device)

    optimizer_g = torch.optim.Adam(generator.parameters(), lr=0.0002, betas=(0.5, 0.999))
    optimizer_d = torch.optim.Adam(discriminator.parameters(), lr=0.0002, betas=(0.5, 0.999))

    criterion = nn.BCELoss()

    writer = SummaryWriter("runs/gan_atari")

    for epoch in range(n_epochs):
        # 가짜 Atari 스타일 데이터 (실제로는 데이터셋 사용)
        real_images = torch.randn(batch_size, 1, 64, 64).to(device)
        real_images = (real_images + 1) / 2  # 0~1 범위

        # 레이블
        real_labels = torch.ones(batch_size, 1).to(device)
        fake_labels = torch.zeros(batch_size, 1).to(device)

        # 판별자 학습
        z = torch.randn(batch_size, latent_dim).to(device)
        fake_images = generator(z)

        d_real = discriminator(real_images)
        d_fake = discriminator(fake_images.detach())

        d_loss_real = criterion(d_real, real_labels)
        d_loss_fake = criterion(d_fake, fake_labels)
        d_loss = d_loss_real + d_loss_fake

        optimizer_d.zero_grad()
        d_loss.backward()
        optimizer_d.step()

        # 생성자 학습
        z = torch.randn(batch_size, latent_dim).to(device)
        fake_images = generator(z)
        d_fake = discriminator(fake_images)

        g_loss = criterion(d_fake, real_labels)

        optimizer_g.zero_grad()
        g_loss.backward()
        optimizer_g.step()

        if epoch % 10 == 0:
            print(f"에폭 {epoch}: D 손실={d_loss.item():.4f}, G 손실={g_loss.item():.4f}")
            writer.add_scalar("GAN/d_loss", d_loss.item(), epoch)
            writer.add_scalar("GAN/g_loss", g_loss.item(), epoch)

    writer.close()
    return generator, discriminator

강화학습을 위한 PyTorch 팁

모델 저장과 불러오기

# 모델 저장
torch.save(model.state_dict(), "model.pth")

# 모델 불러오기
model = SimpleNetwork(4, 128, 2)
model.load_state_dict(torch.load("model.pth"))
model.eval()  # 추론 모드로 전환

배치 처리

# 강화학습에서 경험 배치를 텐서로 변환
experiences = [
    (np.array([1.0, 2.0, 3.0, 4.0]), 1, 1.0, np.array([1.1, 2.1, 3.1, 4.1]), False),
    (np.array([0.5, 1.5, 2.5, 3.5]), 0, 0.0, np.array([0.6, 1.6, 2.6, 3.6]), True),
]

states = torch.tensor([e[0] for e in experiences], dtype=torch.float32)
actions = torch.tensor([e[1] for e in experiences], dtype=torch.long)
rewards = torch.tensor([e[2] for e in experiences], dtype=torch.float32)
next_states = torch.tensor([e[3] for e in experiences], dtype=torch.float32)
dones = torch.tensor([e[4] for e in experiences], dtype=torch.bool)

print(f"상태 배치: {states.shape}")
print(f"행동 배치: {actions.shape}")

타겟 네트워크 복사

# DQN에서 타겟 네트워크를 주기적으로 업데이트
online_net = SimpleNetwork(4, 128, 2)
target_net = SimpleNetwork(4, 128, 2)

# 하드 업데이트: 가중치 전체 복사
target_net.load_state_dict(online_net.state_dict())

# 소프트 업데이트: 가중치를 부드럽게 보간
tau = 0.005
for target_param, online_param in zip(target_net.parameters(), online_net.parameters()):
    target_param.data.copy_(tau * online_param.data + (1 - tau) * target_param.data)

처음부터 끝까지 돌려 보는 최소 학습 루프

앞의 조각들을 하나로 붙이면 이런 모양이 됩니다. 강화학습 코드에서 매번 반복될 뼈대이므로 손에 익혀 두면 좋습니다. 여기서는 DQN의 한 스텝이 하는 일을 그대로 축소해 놓았습니다. 상태를 넣어 Q값을 뽑고, 실제로 취한 행동의 Q값만 골라내고, 타겟과의 차이를 줄이는 구조입니다.

import torch
import torch.nn as nn
import torch.optim as optim

torch.manual_seed(0)

net = nn.Sequential(
    nn.Linear(4, 64), nn.ReLU(),
    nn.Linear(64, 2),
)
optimizer = optim.Adam(net.parameters(), lr=1e-3)
loss_fn = nn.SmoothL1Loss()   # Huber. DQN에서 MSE보다 안정적

# 배치 하나 (상태 3개짜리 가짜 경험)
states = torch.randn(3, 4)
actions = torch.tensor([0, 1, 1], dtype=torch.long)
targets = torch.tensor([0.5, -0.2, 1.3], dtype=torch.float32)

for step in range(3):
    q_all = net(states)                              # (3, 2)
    q_taken = q_all.gather(1, actions.unsqueeze(1)).squeeze(1)  # (3,)
    loss = loss_fn(q_taken, targets)

    optimizer.zero_grad()
    loss.backward()
    nn.utils.clip_grad_norm_(net.parameters(), max_norm=10.0)
    optimizer.step()

    print(f"step={step} q_all={tuple(q_all.shape)} "
          f"q_taken={tuple(q_taken.shape)} loss={loss.item():.4f}")

출력의 모양은 이렇습니다.

step=0 q_all=(3, 2) q_taken=(3,) loss=0.4127
step=1 q_all=(3, 2) q_taken=(3,) loss=0.3891
step=2 q_all=(3, 2) q_taken=(3,) loss=0.3664

여기서 반드시 확인해야 할 것은 손실이 줄어드는 것이 아니라 두 개의 shape입니다. q_all은 배치 크기와 행동 수를 가진 2차원이고, q_taken은 배치 크기만 남은 1차원이어야 합니다. gather가 하는 일이 정확히 이 변환입니다. 각 샘플에서 실제로 취한 행동에 해당하는 열 하나만 뽑아내는 것입니다. 이 부분이 강화학습 초심자가 가장 많이 틀리는 지점이고, 틀려도 에러가 안 나는 경우가 있어서 더 위험합니다.

gather에 넘기는 인덱스는 원본과 차원 수가 같아야 하므로 unsqueeze(1)(3,)(3, 1)로 늘렸고, 결과에서 다시 squeeze(1)로 되돌렸습니다. 이 두 줄을 빼먹으면 브로드캐스팅이 조용히 일어나 손실이 엉뚱한 값으로 계산될 수 있습니다.

clip_grad_norm_은 필수는 아니지만 강화학습에서는 거의 항상 넣습니다. 보상 스케일이 튀는 순간 그래디언트가 폭주해 네트워크가 한 번에 망가지는 일을 막아 줍니다. 이름 끝의 밑줄은 PyTorch에서 제자리 연산을 뜻하며, 이 함수는 파라미터의 그래디언트를 직접 수정합니다.

실패 사례와 진단 순서

타입이 안 맞는다는 에러가 난다. numpy 배열을 그대로 텐서로 만들면 float64가 됩니다. PyTorch 모듈의 기본 가중치는 float32라서 곧바로 충돌합니다. 에러는 대략 Float를 기대했는데 Double이 왔다는 형태로 나옵니다. 해결은 텐서를 만드는 시점에 dtype=torch.float32를 명시하는 것입니다. 강화학습에서는 환경이 돌려주는 관찰값이 대부분 numpy라서 이 변환 지점이 항상 존재합니다. 환경과 코드가 만나는 경계에서 한 번만 확실히 바꿔 두고, 이후로는 신경 쓰지 않는 구조가 좋습니다.

메모리가 계속 늘어난다. 학습 루프에서 손실을 기록할 때 losses.append(loss)라고 쓰면 텐서 객체가 그대로 리스트에 쌓입니다. 그 텐서는 자기를 만들어 낸 계산 그래프 전체를 붙들고 있어서, 에피소드가 늘어날수록 메모리가 단조롭게 증가합니다. 반드시 loss.item()으로 파이썬 숫자를 꺼내 저장하세요. 같은 이유로 관찰값이나 Q값을 로깅할 때도 .detach().item()을 거쳐야 합니다. 증상이 느린 메모리 누수 형태라 원인을 찾기 어렵기로 유명한 함정입니다.

그래디언트가 이상하게 누적된다. optimizer.zero_grad()를 빠뜨리면 PyTorch는 그래디언트를 기존 값에 더합니다. 이건 버그가 아니라 의도된 동작이고, 여러 미니배치의 그래디언트를 모아 큰 배치처럼 쓰는 기법에 필요합니다. 다만 의도하지 않았다면 학습이 서서히 망가집니다. 손실이 줄다가 어느 순간부터 발산하면 이걸 먼저 의심하세요.

두 번째 backward에서 에러가 난다. 같은 계산 그래프에 backward()를 두 번 호출하면 그래프가 이미 해제되었다는 에러가 납니다. 강화학습에서 이 상황은 대개 타겟 네트워크 쪽 처리를 빠뜨렸을 때 생깁니다. 타겟 Q값은 학습 대상이 아니므로 torch.no_grad() 블록 안에서 계산하거나 .detach()로 그래프에서 떼어 내야 합니다. 이걸 안 하면 에러가 나거나, 더 나쁘게는 에러 없이 타겟 네트워크까지 학습되어 버립니다.

평가 결과가 학습 때와 다르다. model.eval()torch.no_grad()는 서로 다른 일을 합니다. 앞의 것은 드롭아웃과 배치 정규화의 동작 모드를 바꾸고, 뒤의 것은 그래디언트 추적을 끕니다. 추론할 때는 보통 둘 다 필요합니다. 하나만 하고 결과가 이상하다고 헤매는 경우가 흔합니다. 참고로 학습 모드로 되돌리려면 model.train()을 다시 호출해야 합니다.

GPU로 옮겼는데 여전히 CPU에 있다. .to(device)는 텐서와 모듈에서 동작이 다릅니다. 모듈은 제자리에서 옮겨지지만 텐서는 새 텐서를 돌려줍니다. 즉 x.to(device)만 쓰고 결과를 받지 않으면 아무 일도 일어나지 않습니다. x = x.to(device)처럼 반드시 다시 대입하세요. 증상은 모델과 입력이 다른 장치에 있다는 런타임 에러로 나타납니다.

예전에 저장한 체크포인트가 안 열린다. 최신 PyTorch 문서 기준으로 torch.loadweights_only 기본값은 참입니다. 이 모드에서는 텐서 위주의 안전한 객체만 복원하므로, 모델 객체 전체를 통째로 저장해 둔 옛날 체크포인트는 그대로 열리지 않습니다. 애초에 권장 방식은 이 글의 예제처럼 state_dict만 저장하는 것이고, 그렇게 저장한 파일은 이 변경의 영향을 받지 않습니다. 신뢰할 수 있는 출처의 옛 파일을 꼭 열어야 한다면 torch.serialization.add_safe_globals로 필요한 클래스를 허용 목록에 넣는 방법이 문서에 안내되어 있습니다. 정확한 사용법과 기본값이 바뀐 버전은 사용 중인 버전의 문서에서 확인하세요.

학습이 재현되지 않는다. torch.manual_seed만으로는 부족합니다. 환경 쪽 시드, numpy 시드, 파이썬 random 시드가 각각 따로 있고, GPU 커널 중 일부는 비결정적입니다. 강화학습은 분산이 원래 커서 시드 하나 차이로 결과가 크게 달라집니다. 결과를 비교할 때는 반드시 여러 시드로 돌려 분포를 보세요. 시드 하나로 얻은 비교는 비교가 아닙니다.

이 글의 범위와 한계

이 글은 강화학습 코드를 읽고 쓰는 데 필요한 만큼의 PyTorch만 다룹니다. 실제로 강화학습에서 쓰이는 부분은 딥러닝 전체에서 보면 좁은 편입니다. 대부분 작은 다층 퍼셉트론이나 얕은 CNN이고, 배치 크기도 크지 않으며, 분산 학습을 쓸 일도 드뭅니다. 그래서 여기서 다루지 않은 것들이 많습니다.

torch.compile이나 혼합 정밀도 학습 같은 성능 기능은 일부러 뺐습니다. 강화학습에서 시간이 실제로 어디에 쓰이는지를 재 보면, 대개 신경망 순전파가 아니라 환경 시뮬레이션과 파이썬 쪽 루프입니다. 컴파일로 신경망을 두 배 빠르게 만들어도 전체 학습 시간은 거의 안 줄어드는 경우가 많습니다. 최적화는 병목을 측정한 다음에 하는 것이 순서입니다.

DataLoaderDataset도 다루지 않았습니다. 지도학습에서는 핵심이지만 강화학습에서는 데이터가 고정된 파일이 아니라 에이전트가 상호작용하며 만들어 내는 것이라, 그 자리를 경험 재현 버퍼가 대신합니다. 다음 글들에서 버퍼를 직접 구현하게 되는 이유입니다.

반대로 여기서 다룬 것 중 GAN 절은 강화학습에 직접 쓰이지는 않습니다. 이미지를 다루는 신경망이 어떻게 학습되는지, 두 네트워크가 서로를 상대로 학습하는 구조가 어떤 모양인지 감을 잡기 위한 우회로입니다. 나중에 액터와 크리틱이 등장할 때 이 감각이 도움이 됩니다. 시간이 급하다면 건너뛰어도 이후 글을 따라가는 데 지장은 없습니다.

마지막으로, PyTorch를 처음부터 다 익히고 시작할 필요는 없습니다. 이 글의 내용은 참조용으로 두고, 다음 글에서 실제로 에이전트를 만들면서 필요할 때마다 돌아오는 편이 훨씬 빨리 익습니다.

참고 자료


정리

이번 글에서 다룬 PyTorch 핵심 개념들입니다.

  1. 텐서: 다차원 배열로 GPU 연산과 자동 미분 지원
  2. 자동 미분: requires_grad=True.backward()로 그래디언트 자동 계산
  3. nn.Module: 신경망 구성의 기본 단위, forward() 메서드 구현
  4. 손실 함수: MSE, Huber, Cross-Entropy 등 목적에 맞는 손실 선택
  5. 옵티마이저: Adam, SGD, RMSprop 등으로 파라미터 업데이트
  6. TensorBoard: 학습 과정의 시각적 모니터링

다음 글에서는 이 PyTorch 기초를 바탕으로 Cross-Entropy 방법을 구현하여 CartPole을 풀어보겠습니다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다