개념
두 네트워크
flowchart LR
Z["z ~ N(0, I)<br/>noise"] --> G["Generator<br/>transposed convs"]
G --> FAKE["Fake image"]
REAL["Real image"] --> D["Discriminator<br/>conv classifier"]
FAKE --> D
D --> OUT["P(real)"]
style G fill:#dbeafe,stroke:#2563eb
style D fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
생성자(Generator) G는 잡음 벡터(noise vector) z를 받아 이미지를 출력합니다. 판별자(Discriminator) D는 이미지를 받아 그 이미지가 진짜일 확률을 나타내는 스칼라(scalar) 값 하나를 출력합니다.
게임
G는 D가 틀리기를 원합니다. D는 맞히기를 원합니다. 형식적으로는 다음과 같습니다.
min_G max_D E_x[log D(x)] + E_z[log(1 - D(G(z)))]
오른쪽에서 왼쪽으로 읽으면 됩니다. D는 진짜 이미지에 대한 log D(real)과 가짜 이미지에 대한 log (1 - D(fake))를 최대화합니다. G는 가짜 이미지에 대한 D의 정확도를 최소화합니다. 즉 D(G(z))가 높아지기를 원합니다.
Goodfellow는 이 미니맥스가 p_G = p_data, D가 모든 곳에서 0.5를 출력하고 생성 분포(generated distribution)와 실제 분포(real distribution) 사이의 젠슨-섀넌 발산(Jensen-Shannon divergence)이 0이 되는 전역 균형점(global equilibrium)을 갖는다는 사실을 증명했습니다. 어려운 부분은 그 지점까지 도달하는 것입니다.
비포화 손실(Non-saturating loss)
위 형태는 수치적으로 불안정합니다. 학습 초기에는 모든 가짜 이미지에 대해 D(G(z))가 0에 가깝기 때문에 log(1 - D(G(z)))의 기울기(gradient)가 G 입장에서 거의 사라집니다. 해결책은 G의 손실을 뒤집는 것입니다.
L_D = -E_x[log D(x)] - E_z[log(1 - D(G(z)))]
L_G = -E_z[log D(G(z))] # non-saturating
이제 D(G(z))가 0에 가까울 때 G의 손실은 크고 기울기도 유용한 정보를 담습니다. 현대 GAN은 거의 모두 이 변형으로 학습합니다.
DCGAN 아키텍처 규칙
Radford, Metz, Chintala(2015)는 수년간의 실패한 실험을 GAN 학습을 안정화하는 다섯 가지 규칙으로 정리했습니다.
- 두 네트워크 모두 풀링(pooling) 대신 스트라이드 합성곱(strided convolution)을 사용합니다.
- 생성자와 판별자 모두 배치 정규화(batch norm)를 사용하되, G의 출력과 D의 입력 계층은 예외로 둡니다.
- 깊은 아키텍처에서는 완전 연결 계층(fully connected layer)을 제거합니다.
- G는 출력 계층을 제외한 모든 계층에서 ReLU를 쓰고, 출력은
[-1, 1] 범위로 매핑하는 tanh를 사용합니다.
- D는 모든 계층에서 LeakyReLU(
negative_slope=0.2)를 사용합니다.
StyleGAN, BigGAN, GigaGAN 같은 현대 합성곱 기반 GAN도 여전히 이 규칙에서 출발해 한 부분씩 바꿉니다.
실패 모드와 신호
flowchart LR
M1["Mode collapse<br/>G produces a narrow<br/>set of outputs"] --> S1["D loss low,<br/>G loss oscillating,<br/>sample variety drops"]
M2["Vanishing gradients<br/>D wins completely"] --> S2["D accuracy ~100%,<br/>G loss huge and static"]
M3["Oscillation<br/>G and D keep trading<br/>wins forever"] --> S3["Both losses swing<br/>wildly with no downward trend"]
style M1 fill:#fecaca,stroke:#dc2626
style M2 fill:#fecaca,stroke:#dc2626
style M3 fill:#fecaca,stroke:#dc2626
- 모드 붕괴(Mode collapse): G가 D를 속이는 이미지 하나를 찾아 그것만 만듭니다. 미니배치 판별(minibatch discrimination), 스펙트럴 정규화(spectral norm), 레이블 조건부(label conditioning)를 추가해 완화합니다.
- 판별자 완승(Discriminator wins): D가 너무 빨리 강해져 G의 기울기가 사라집니다. D를 작게 만들거나 D의 학습률(learning rate)을 낮추거나, 진짜 레이블에 레이블 스무딩(label smoothing)을 적용합니다.
- 진동(Oscillation): 두 네트워크가 균형에 접근하지 못하고 계속 서로 이겼다 졌다 합니다. TTUR, 즉 D를 G보다 2-4배 빠르게 학습시키거나 바서스타인 손실(Wasserstein loss)로 바꿉니다.
평가
GAN에는 정답 이미지가 없는데, 어떻게 작동 여부를 판단할까요?
- 샘플 검사(Sample inspection) — 매 에포크(epoch) 끝마다 64개 샘플을 직접 봅니다. 생략할 수 없습니다.
- FID(Fréchet Inception Distance) — 진짜 집합과 생성 집합의 Inception-v3 특징 분포(feature distribution) 사이의 거리입니다. 낮을수록 좋고, 커뮤니티 표준입니다.
- Inception Score — 오래되고 더 취약합니다. 가능하면 FID를 선호합니다.
- 생성 모델용 정밀도/재현율(Precision/Recall for generative models) — 품질(precision)과 포괄성(recall)을 따로 측정합니다. FID 하나보다 더 많은 정보를 줍니다.
작은 합성 데이터 실험에서는 샘플 검사만으로 충분합니다.
만들어보기
Step 1: 생성자(Generator)
64차원 잡음(noise)을 입력받아 32x32 이미지를 만드는 작은 DCGAN 생성자입니다.
import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, z_dim=64, img_channels=3, feat=64):
super().__init__()
self.net = nn.Sequential(
nn.ConvTranspose2d(z_dim, feat * 4, kernel_size=4, stride=1, padding=0, bias=False),
nn.BatchNorm2d(feat * 4),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat * 4, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 2),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat * 2, feat, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(feat, img_channels, kernel_size=4, stride=2, padding=1, bias=False),
nn.Tanh(),
)
def forward(self, z):
return self.net(z.view(z.size(0), -1, 1, 1))
네 개의 전치 합성곱(transposed conv)을 사용하며, kernel_size=4, stride=2, padding=1 조합으로 공간 크기를 깔끔하게 두 배씩 키웁니다. 출력 활성화(activation)는 tanh를 통해 [-1, 1] 범위에 놓입니다.
Step 2: 판별자(Discriminator)
생성자를 거꾸로 뒤집은 대칭(mirror) 구조입니다. LeakyReLU와 스트라이드 합성곱을 사용하고 스칼라 로짓(logit) 하나로 끝납니다.
class Discriminator(nn.Module):
def __init__(self, img_channels=3, feat=64):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(img_channels, feat, kernel_size=4, stride=2, padding=1),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 2),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat * 2, feat * 4, kernel_size=4, stride=2, padding=1, bias=False),
nn.BatchNorm2d(feat * 4),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(feat * 4, 1, kernel_size=4, stride=1, padding=0),
)
def forward(self, x):
return self.net(x).view(-1)
마지막 합성곱은 4x4 특징 맵(feature map)을 1x1로 줄입니다. 출력은 이미지당 스칼라 하나입니다. sigmoid는 손실 계산 때만 적용합니다.
Step 3: 학습 단계
배치(batch)마다 D를 한 번 업데이트하고, 이어서 G를 한 번 업데이트합니다.
import torch.nn.functional as F
def train_step(G, D, real, z, opt_g, opt_d, device):
real = real.to(device)
bs = real.size(0)
opt_d.zero_grad()
d_real = D(real)
d_fake = D(G(z).detach())
loss_d = (F.binary_cross_entropy_with_logits(d_real, torch.ones_like(d_real))
+ F.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))
loss_d.backward()
opt_d.step()
opt_g.zero_grad()
d_fake = D(G(z))
loss_g = F.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))
loss_g.backward()
opt_g.step()
return loss_d.item(), loss_g.item()
D 단계의 G(z).detach()가 중요합니다. D를 업데이트하는 동안 G 쪽으로 기울기가 흘러가면 안 됩니다. 이것을 빠뜨리는 것이 GAN 초보자가 가장 흔히 마주치는 버그입니다.
Step 4: 합성 도형 데이터로 전체 학습 루프 돌리기
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
def synthetic_images(num=2000, size=32, seed=0):
rng = np.random.default_rng(seed)
imgs = np.zeros((num, 3, size, size), dtype=np.float32) - 1.0
for i in range(num):
r = rng.uniform(6, 12)
cx, cy = rng.uniform(r, size - r, size=2)
yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
color = rng.uniform(-0.5, 1.0, size=3)
for c in range(3):
imgs[i, c][mask] = color[c]
return torch.from_numpy(imgs)
device = "cuda" if torch.cuda.is_available() else "cpu"
data = synthetic_images()
loader = DataLoader(TensorDataset(data), batch_size=64, shuffle=True)
G = Generator(z_dim=64, img_channels=3, feat=32).to(device)
D = Discriminator(img_channels=3, feat=32).to(device)
opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
for epoch in range(10):
for (batch,) in loader:
z = torch.randn(batch.size(0), 64, device=device)
ld, lg = train_step(G, D, batch, z, opt_g, opt_d, device)
print(f"에포크 {epoch} D {ld:.3f} G {lg:.3f}")
Adam(lr=2e-4, betas=(0.5, 0.999))는 DCGAN 기본값입니다. 낮은 beta1은 모멘텀(momentum) 항이 적대적 게임(adversarial game)을 지나치게 안정화시키며 반응을 늦추는 일을 줄여 줍니다.
Step 5: 샘플링(Sampling)
@torch.no_grad()
def sample(G, n=16, z_dim=64, device="cpu"):
G.eval()
z = torch.randn(n, z_dim, device=device)
imgs = G(z)
imgs = (imgs + 1) / 2
return imgs.clamp(0, 1)
샘플링 전에는 항상 평가 모드(eval mode)로 전환합니다. DCGAN에서는 배치 정규화가 배치 통계 대신 누적 통계(running stats)를 사용하므로 이 차이가 중요합니다.
Step 6: 스펙트럴 정규화(Spectral normalization)
판별자의 배치 정규화(BN)를 그대로 갈아 끼울 수 있는(drop-in) 안정화 기법입니다. 네트워크가 1-립시츠(1-Lipschitz)에 가깝게 제한되도록 도와 대부분의 "D가 너무 세게 이기는" 실패를 줄입니다.
from torch.nn.utils import spectral_norm
def build_sn_discriminator(img_channels=3, feat=64):
return nn.Sequential(
spectral_norm(nn.Conv2d(img_channels, feat, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat, feat * 2, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat * 2, feat * 4, 4, 2, 1)),
nn.LeakyReLU(0.2, inplace=True),
spectral_norm(nn.Conv2d(feat * 4, 1, 4, 1, 0)),
)
Discriminator를 build_sn_discriminator()로 바꾸면 TTUR 없이도 안정화되는 경우가 많습니다. 스펙트럴 정규화는 가장 쉽게 적용할 수 있는 단일 강건성 개선책(robustness upgrade)입니다.
활용하기
본격적인 이미지 생성을 하려면 사전 학습된 가중치(pretrained weights)를 사용하거나 확산 모델(diffusion)로 전환하는 편이 좋습니다. 표준 라이브러리는 다음과 같습니다.
torch_fidelity는 별도의 평가 코드를 직접 작성하지 않아도 생성자의 FID / IS를 계산해 줍니다.
pytorch-gan-zoo(legacy)와 StudioGAN은 DCGAN, WGAN-GP, SN-GAN, StyleGAN, BigGAN의 검증된 구현을 제공합니다.
2026년 기준으로 GAN은 여전히 실시간 이미지 생성(지연시간 10 ms 미만), 스타일 전이(style transfer), 정밀한 제어가 필요한 이미지 간 변환(image-to-image translation; Pix2Pix, CycleGAN)에 좋은 선택입니다. 사진과 같은 현실성(photorealism)과 텍스트 조건부(text conditioning)에서는 확산 모델이 우세합니다.
산출물 만들기
이 레슨의 최종 산출물은 다음과 같습니다.
outputs/prompt-gan-training-triage.md — 학습 곡선(training curve) 설명을 읽고 모드 붕괴, 판별자 완승, 진동 같은 실패 모드와 하나의 권장 수정책을 고르는 프롬프트(prompt)입니다.
outputs/skill-dcgan-scaffold.md — z_dim, 목표 image_size, num_channels를 입력받아 학습 루프와 샘플 저장기를 포함한 DCGAN 스캐폴드(scaffold)를 작성하는 스킬(skill)입니다.
연습문제
- (쉬움) 위 DCGAN을 합성 원 데이터셋에서 학습하고, 각 에포크 끝마다 16개 샘플 그리드를 저장합니다. 몇 번째 에포크부터 생성된 원이 명확히 원처럼 보이나요?
- (중간) 판별자의 배치 정규화를 스펙트럴 정규화로 바꿉니다. 두 버전을 나란히 학습합니다. 어느 쪽이 더 빨리 수렴하나요? 세 개의 시드(seed)에서 어느 쪽의 분산(variance)이 더 낮나요?
- (어려움) 조건부(conditional) DCGAN을 구현합니다. 클래스 레이블(class label)을 G와 D 양쪽에 넣습니다. G에서는 원-핫(one-hot)을 잡음에 이어 붙이고(concat), D에서는 클래스 임베딩 채널(class embedding channel)을 이어 붙입니다. Lesson 7의 합성 "원 vs 사각형(circles vs squares)" 데이터셋에서 학습한 뒤, 특정 레이블로 샘플링해 클래스 조건부(class conditioning)가 작동함을 보여줍니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 생성자(Generator, G) | "그림 그리는 망" | 잡음을 이미지로 매핑하며 판별자를 속이도록 학습됩니다. |
| 판별자(Discriminator, D) | "비평가" | 진짜 이미지와 생성 이미지를 구분하도록 학습되는 이진 분류기(binary classifier)입니다. |
| 미니맥스(Minimax) | "그 게임" | 적대적 손실(adversarial loss)에 대해 G는 최소화하고 D는 최대화합니다. 균형점은 p_G = p_data입니다. |
| 비포화 손실(Non-saturating loss) | "수치적으로 제정신인 버전" | 초기 기울기 소실을 피하기 위해 G의 손실을 log(1 - D(G(z))) 대신 -log(D(G(z)))로 둡니다. |
| 모드 붕괴(Mode collapse) | "생성자가 한 가지만 만든다" | G가 데이터 분포의 작은 부분 집합만 생성합니다. 스펙트럴 정규화(SN), 미니배치 판별, 큰 배치 등으로 완화합니다. |
| TTUR | "두 학습률" | 보통 D가 G보다 2-4배 빠르게 학습합니다. 학습을 안정화합니다. |
| 스펙트럴 정규화(Spectral norm) | "1-립시츠 계층" | 각 계층의 립시츠 상수(Lipschitz constant)를 제한하는 가중치 정규화(weight-normalization)입니다. D가 임의로 가파르게 변하는 것을 막습니다. |
| FID | "Fréchet Inception Distance" | 진짜 집합과 생성 집합의 Inception-v3 특징 분포 사이의 거리입니다. 표준 평가 지표입니다. |
더 읽을거리