이미지 로딩 중...
AI Generated
2025. 11. 8. · 5 Views
AI 이미지 생성 1편 - 생성 모델의 발전사
GAN부터 Diffusion Model까지, AI가 어떻게 이미지를 생성하게 되었는지 그 발전 과정을 코드와 함께 살펴봅니다. 각 모델의 핵심 원리와 한계점, 그리고 어떻게 개선되어 왔는지 초급 개발자도 쉽게 이해할 수 있도록 설명합니다.
목차
1. VAE
시작하며
여러분이 JPEG 이미지를 저장할 때, 파일 크기가 원본보다 작아지는 것을 본 적 있나요? 이것은 압축 알고리즘이 중요한 정보만 남기고 나머지는 버리기 때문입니다.
그런데 만약 AI가 이미지의 '본질'만 학습해서 새로운 이미지를 만들어낼 수 있다면 어떨까요? VAE(Variational AutoEncoder)는 바로 이런 아이디어에서 시작되었습니다.
2013년에 등장한 VAE는 이미지 생성 모델의 시초라고 할 수 있습니다. 기존 AutoEncoder가 단순히 압축과 복원만 했다면, VAE는 여기에 '확률'이라는 개념을 도입했습니다.
이 모델은 이미지를 작은 잠재 공간(latent space)으로 압축한 뒤, 그 공간에서 샘플링하여 새로운 이미지를 생성할 수 있습니다. 마치 이미지의 DNA를 추출해서 새로운 이미지를 복제하는 것과 비슷합니다.
개요
간단히 말해서, VAE는 이미지를 압축했다가 다시 복원하는 과정에서 새로운 이미지를 생성할 수 있는 모델입니다. 왜 이것이 혁신적이었을까요?
기존에는 이미지를 생성하려면 복잡한 규칙을 일일이 프로그래밍해야 했습니다. 하지만 VAE는 데이터를 보고 스스로 학습하여 이미지의 특징을 추출합니다.
예를 들어, 얼굴 이미지를 학습시키면 눈, 코, 입의 위치와 모양 같은 특징을 자동으로 파악합니다. 기존의 AutoEncoder는 입력 이미지를 그대로 복원하는 것이 목표였습니다.
하지만 VAE는 잠재 공간에 확률 분포를 가정하여, 학습하지 않은 새로운 이미지도 생성할 수 있게 되었습니다. VAE의 핵심 특징은 두 가지입니다: Encoder는 이미지를 평균(μ)과 분산(σ)으로 표현하고, Decoder는 이 분포에서 샘플링한 값으로 이미지를 재생성합니다.
이렇게 확률적 접근을 통해 다양성 있는 이미지 생성이 가능해진 것이 VAE의 가장 큰 혁신입니다.
코드 예제
import torch
import torch.nn as nn
class VAE(nn.Module):
def __init__(self, latent_dim=128):
super(VAE, self).__init__()
# Encoder: 이미지를 잠재 공간으로 압축
self.encoder = nn.Sequential(
nn.Linear(784, 400), # 28x28 이미지를 400차원으로
nn.ReLU()
)
# 평균과 분산을 각각 계산
self.fc_mu = nn.Linear(400, latent_dim) # 평균 μ
self.fc_logvar = nn.Linear(400, latent_dim) # 로그 분산 log(σ²)
# Decoder: 잠재 벡터를 이미지로 복원
self.decoder = nn.Sequential(
nn.Linear(latent_dim, 400),
nn.ReLU(),
nn.Linear(400, 784),
nn.Sigmoid() # 픽셀 값을 0~1 사이로 정규화
)
def reparameterize(self, mu, logvar):
# Reparameterization trick: μ + σ * ε (ε는 표준정규분포)
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def forward(self, x):
# Encoding
h = self.encoder(x)
mu, logvar = self.fc_mu(h), self.fc_logvar(h)
# Sampling
z = self.reparameterize(mu, logvar)
# Decoding
return self.decoder(z), mu, logvar
설명
이것이 하는 일: VAE는 이미지를 저차원 잠재 공간으로 압축하고, 그 공간에서 샘플링하여 다시 이미지로 복원합니다. 핵심은 단순히 복원하는 것이 아니라, 확률 분포를 학습하여 새로운 이미지를 생성할 수 있다는 점입니다.
첫 번째로, Encoder가 입력 이미지(784차원의 MNIST 손글씨)를 받아 400차원으로 압축합니다. 그 다음 두 개의 완전연결층(fc_mu, fc_logvar)이 각각 평균 μ와 로그 분산 log(σ²)을 계산합니다.
왜 두 개로 나누었을까요? 이미지의 특징을 단일 값이 아닌 '범위'로 표현하기 위해서입니다.
예를 들어 숫자 '3'을 표현할 때, 정확히 하나의 형태만 있는 것이 아니라 사람마다 쓰는 방식이 조금씩 다르기 때문입니다. 두 번째 단계에서 가장 중요한 reparameterization trick이 실행됩니다.
z = μ + σ * ε 공식을 통해 잠재 벡터 z를 샘플링합니다. 여기서 ε는 표준정규분포에서 랜덤하게 뽑은 값입니다.
이 트릭이 왜 필요할까요? 일반적인 샘플링은 미분이 불가능해서 역전파로 학습할 수 없습니다.
하지만 이 방법을 쓰면 μ와 σ에 대해 미분이 가능해져서 학습이 가능해집니다. 세 번째로 Decoder가 샘플링된 잠재 벡터 z를 받아서 다시 400차원으로 확장하고, 최종적으로 784차원의 이미지로 복원합니다.
Sigmoid 함수를 마지막에 적용하여 픽셀 값이 0과 1 사이가 되도록 정규화합니다. 여러분이 이 모델을 학습시키면, 잠재 공간에서 임의의 점을 샘플링하여 전혀 새로운 손글씨 숫자를 생성할 수 있습니다.
또한 두 이미지의 잠재 벡터를 보간(interpolation)하면 자연스럽게 변화하는 이미지 시퀀스도 만들 수 있습니다. 예를 들어 '3'에서 '8'로 서서히 변하는 과정을 시각화할 수 있죠.
VAE의 손실 함수는 두 부분으로 구성됩니다: Reconstruction Loss(원본과 복원 이미지의 차이)와 KL Divergence(잠재 분포와 표준정규분포의 차이). 이 두 가지를 동시에 최소화하면서 학습이 진행됩니다.
실전 팁
💡 latent_dim(잠재 차원)을 너무 작게 설정하면 이미지 디테일이 손실되고, 너무 크게 하면 overfitting이 발생합니다. MNIST는 128, 복잡한 이미지는 512 정도가 적당합니다.
💡 KL Divergence의 가중치를 조절하는 β-VAE를 사용하면 잠재 공간의 disentanglement(특징 분리)를 개선할 수 있습니다. β 값을 1보다 크게 설정해보세요.
💡 VAE로 생성된 이미지가 흐릿하다면 정상입니다. 이는 MSE Loss의 특성 때문이며, 이를 해결하기 위해 GAN이 등장하게 됩니다.
💡 학습 시 reconstruction loss와 KL loss를 따로 모니터링하세요. KL loss가 0에 가까우면 posterior collapse(모든 이미지가 같은 잠재 벡터로 매핑) 문제가 발생한 것입니다.
💡 이미지 생성 시 torch.randn()으로 잠재 벡터를 샘플링하면 다양한 결과를 얻을 수 있습니다. 같은 벡터를 여러 번 사용하면 동일한 이미지가 생성됩니다.
2. GAN의_등장
시작하며
여러분이 위조지폐범과 경찰의 추격전을 상상해보세요. 위조범은 점점 더 정교한 가짜 지폐를 만들고, 경찰은 그것을 구분하는 능력을 키웁니다.
이 경쟁이 반복되면 결국 진짜와 구별할 수 없는 완벽한 위조지폐가 탄생하겠죠? 2014년 Ian Goodfellow가 제안한 GAN(Generative Adversarial Network)은 바로 이런 아이디어를 AI에 적용한 것입니다.
두 개의 신경망이 서로 경쟁하면서 발전한다는 개념은 당시로서는 매우 혁신적이었습니다. VAE가 흐릿한 이미지를 생성하는 한계를 보였다면, GAN은 놀라울 정도로 선명하고 현실적인 이미지를 만들어낼 수 있었습니다.
이는 이미지 생성 기술의 판도를 완전히 바꾸어 놓았습니다.
개요
간단히 말해서, GAN은 가짜 이미지를 만드는 Generator와 진짜/가짜를 구분하는 Discriminator가 서로 경쟁하며 학습하는 모델입니다. 왜 이 접근법이 효과적일까요?
VAE는 이미지를 픽셀 단위로 비교하는 MSE Loss를 사용해서 평균적으로 가장 비슷한 이미지를 만들려고 합니다. 그 결과 여러 가능성의 평균값인 흐릿한 이미지가 나옵니다.
하지만 GAN은 Discriminator가 "이건 가짜야"라고 판별하지 못하도록 Generator를 학습시킵니다. 예를 들어, 얼굴 생성에서 GAN은 실제 사람처럼 선명한 눈동자와 피부 질감을 만들어냅니다.
기존에는 Loss Function을 사람이 직접 설계해야 했습니다. 하지만 GAN에서는 Discriminator가 자동으로 '좋은 이미지'의 기준을 학습합니다.
이것이 GAN의 가장 큰 혁신입니다. GAN의 핵심 특징: Generator는 랜덤 노이즈에서 이미지를 생성하고, Discriminator는 실제 이미지와 생성된 이미지를 구분합니다.
두 네트워크는 minimax game(최소최대 게임)을 하면서 동시에 학습됩니다. Discriminator가 완벽해지려고 하면 Generator도 더 정교한 가짜를 만들어내고, 이 경쟁이 반복되면서 둘 다 발전합니다.
코드 예제
import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, latent_dim=100, img_dim=784):
super(Generator, self).__init__()
# 랜덤 노이즈를 이미지로 변환
self.model = nn.Sequential(
nn.Linear(latent_dim, 256),
nn.LeakyReLU(0.2),
nn.Linear(256, 512),
nn.LeakyReLU(0.2),
nn.Linear(512, img_dim),
nn.Tanh() # 픽셀 값을 -1~1로 정규화
)
def forward(self, z):
# z: 랜덤 노이즈 벡터 (batch_size, latent_dim)
return self.model(z)
class Discriminator(nn.Module):
def __init__(self, img_dim=784):
super(Discriminator, self).__init__()
# 이미지가 진짜인지 가짜인지 판별
self.model = nn.Sequential(
nn.Linear(img_dim, 512),
nn.LeakyReLU(0.2),
nn.Dropout(0.3), # overfitting 방지
nn.Linear(512, 256),
nn.LeakyReLU(0.2),
nn.Dropout(0.3),
nn.Linear(256, 1),
nn.Sigmoid() # 0(가짜)~1(진짜) 확률
)
def forward(self, img):
return self.model(img)
# 학습 과정
def train_step(generator, discriminator, real_imgs, optimizer_G, optimizer_D):
batch_size = real_imgs.size(0)
# Discriminator 학습
optimizer_D.zero_grad()
# 진짜 이미지는 1로 분류하도록
real_pred = discriminator(real_imgs)
real_loss = nn.BCELoss()(real_pred, torch.ones(batch_size, 1))
# 가짜 이미지는 0으로 분류하도록
z = torch.randn(batch_size, 100)
fake_imgs = generator(z)
fake_pred = discriminator(fake_imgs.detach()) # Generator gradient 차단
fake_loss = nn.BCELoss()(fake_pred, torch.zeros(batch_size, 1))
d_loss = real_loss + fake_loss
d_loss.backward()
optimizer_D.step()
# Generator 학습: Discriminator를 속이도록
optimizer_G.zero_grad()
z = torch.randn(batch_size, 100)
fake_imgs = generator(z)
fake_pred = discriminator(fake_imgs)
g_loss = nn.BCELoss()(fake_pred, torch.ones(batch_size, 1)) # 가짜를 진짜로 착각시키기
g_loss.backward()
optimizer_G.step()
설명
이것이 하는 일: GAN은 두 개의 신경망이 제로섬 게임을 합니다. Generator는 Discriminator를 속이려고 하고, Discriminator는 가짜를 찾아내려고 합니다.
이 경쟁 구조가 고품질 이미지 생성의 핵심입니다. 첫 번째로, Generator는 100차원의 랜덤 노이즈(z)를 입력받아 256차원, 512차원으로 확장하면서 점차 이미지의 특징을 만들어냅니다.
LeakyReLU 활성화 함수를 사용하는 이유는 일반 ReLU보다 gradient flow가 좋아서 학습이 안정적이기 때문입니다. 마지막 Tanh 함수는 출력을 -1에서 1 사이로 제한하여, 실제 이미지의 정규화된 범위와 일치시킵니다.
완전히 랜덤한 노이즈에서 시작해서 의미 있는 이미지가 나온다는 것이 놀랍지 않나요? 두 번째로, Discriminator는 784차원 이미지를 받아서 점차 차원을 축소하며 특징을 추출합니다.
Dropout(0.3)을 중간에 넣은 이유는 Discriminator가 너무 강해지면 Generator가 학습할 기회를 잃기 때문입니다. 마치 너무 강한 선생님은 학생의 발전을 막는 것과 비슷합니다.
최종 Sigmoid 출력은 0(가짜)에서 1(진짜) 사이의 확률값입니다. 세 번째로, 학습 과정이 매우 중요합니다.
먼저 Discriminator를 학습시킵니다: 진짜 이미지는 1로, 가짜 이미지는 0으로 분류하도록 합니다. 이때 fake_imgs.detach()를 사용하여 Generator로의 gradient 흐름을 차단합니다.
그 다음 Generator를 학습시킵니다: 새로운 가짜 이미지를 만들고, Discriminator가 이것을 1(진짜)로 판단하도록 속입니다. 여러분이 이 코드를 실행하면, 처음에는 완전한 노이즈 이미지가 나오다가, 수천 번의 iteration을 거치면서 점차 숫자의 형태가 나타나기 시작합니다.
1만 번쯤 학습하면 사람이 쓴 것 같은 자연스러운 손글씨가 생성됩니다. GAN의 학습은 매우 불안정합니다.
Discriminator가 너무 강하면 Generator가 발전하지 못하고(gradient vanishing), Generator가 너무 강하면 mode collapse(하나의 이미지만 반복 생성)가 발생합니다. 이를 방지하기 위해 학습률 조정, batch normalization, spectral normalization 같은 다양한 기법들이 개발되었습니다.
실전 팁
💡 Discriminator와 Generator의 학습 속도 균형이 중요합니다. 보통 Discriminator를 1회 학습할 때마다 Generator도 1회 학습하지만, 상황에 따라 비율을 조정해야 합니다.
💡 Mode collapse를 발견하면(모든 생성 이미지가 비슷해짐) 학습률을 낮추거나, minibatch discrimination 기법을 사용하세요.
💡 실제 이미지를 -1~1로 정규화하는 것을 잊지 마세요. Generator가 Tanh를 쓰므로 입력 데이터도 같은 범위여야 합니다: imgs = (imgs - 0.5) / 0.5
💡 학습 초반에 Discriminator loss가 0에 가까워지면 위험 신호입니다. Discriminator가 너무 강해서 Generator가 학습하지 못하는 것이므로, Discriminator의 학습률을 낮추세요.
💡 생성 품질을 평가할 때는 Inception Score나 FID(Fréchet Inception Distance) 같은 정량적 지표를 사용하세요. 육안 평가만으로는 부족합니다.
3. DCGAN
시작하며
여러분이 처음 GAN을 학습시켜 보면 금방 좌절하게 됩니다. 학습이 불안정해서 이미지가 갑자기 무너지거나, 하나의 이미지만 계속 생성하는 mode collapse가 발생하기 때문입니다.
이런 문제들이 GAN의 실용화를 막는 큰 장벽이었습니다. 2015년 Radford et al.이 발표한 DCGAN(Deep Convolutional GAN)은 이러한 학습 불안정성 문제를 크게 개선했습니다.
완전연결층(Fully Connected) 대신 합성곱층(Convolutional)을 사용하고, 몇 가지 아키텍처 가이드라인을 제시했습니다. 이 논문은 단순히 새로운 모델을 제안한 것이 아니라, "안정적으로 GAN을 학습시키는 방법"을 체계화했다는 점에서 의미가 큽니다.
DCGAN 이후 GAN 연구가 폭발적으로 증가했습니다.
개요
간단히 말해서, DCGAN은 합성곱 신경망(CNN)을 GAN에 적용하여 더 선명하고 고해상도의 이미지를 안정적으로 생성할 수 있게 만든 모델입니다. 왜 CNN이 효과적일까요?
이미지는 공간적 구조를 가지고 있습니다. 눈 근처에는 눈썹이 있고, 코 옆에는 볼이 있죠.
완전연결층은 이런 공간 정보를 무시하고 모든 픽셀을 독립적으로 처리합니다. 하지만 합성곱층은 이웃한 픽셀들의 관계를 학습하여 훨씬 효율적으로 이미지 특징을 포착합니다.
예를 들어, 얼굴의 대칭성이나 눈의 형태 같은 구조적 패턴을 자연스럽게 학습합니다. 기존 GAN은 완전연결층만 사용해서 28x28 MNIST 정도만 겨우 생성했습니다.
DCGAN은 합성곱층을 도입하여 64x64, 128x128 해상도의 선명한 이미지를 만들 수 있게 되었습니다. DCGAN의 핵심 아키텍처 원칙은 다음과 같습니다: (1) Pooling 대신 Strided Convolution 사용, (2) Batch Normalization 적용, (3) 완전연결층 제거, (4) Generator는 ReLU, Discriminator는 LeakyReLU 사용, (5) Generator 출력은 Tanh.
이 다섯 가지 원칙을 따르면 학습이 훨씬 안정적입니다.
코드 예제
import torch
import torch.nn as nn
class DCGenerator(nn.Module):
def __init__(self, latent_dim=100, channels=3):
super(DCGenerator, self).__init__()
# 노이즈 벡터를 점진적으로 upsampling하여 이미지 생성
self.model = nn.Sequential(
# 입력: (batch, 100, 1, 1) -> (batch, 512, 4, 4)
nn.ConvTranspose2d(latent_dim, 512, 4, 1, 0, bias=False),
nn.BatchNorm2d(512),
nn.ReLU(True),
# (batch, 512, 4, 4) -> (batch, 256, 8, 8)
nn.ConvTranspose2d(512, 256, 4, 2, 1, bias=False),
nn.BatchNorm2d(256),
nn.ReLU(True),
# (batch, 256, 8, 8) -> (batch, 128, 16, 16)
nn.ConvTranspose2d(256, 128, 4, 2, 1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(True),
# (batch, 128, 16, 16) -> (batch, 64, 32, 32)
nn.ConvTranspose2d(128, 64, 4, 2, 1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(True),
# (batch, 64, 32, 32) -> (batch, 3, 64, 64)
nn.ConvTranspose2d(64, channels, 4, 2, 1, bias=False),
nn.Tanh() # 최종 출력을 -1~1로
)
def forward(self, z):
# z를 (batch, 100, 1, 1) 형태로 reshape
z = z.view(z.size(0), z.size(1), 1, 1)
return self.model(z)
class DCDiscriminator(nn.Module):
def __init__(self, channels=3):
super(DCDiscriminator, self).__init__()
# 이미지를 점진적으로 downsampling하여 판별
self.model = nn.Sequential(
# 입력: (batch, 3, 64, 64) -> (batch, 64, 32, 32)
nn.Conv2d(channels, 64, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# (batch, 64, 32, 32) -> (batch, 128, 16, 16)
nn.Conv2d(64, 128, 4, 2, 1, bias=False),
nn.BatchNorm2d(128),
nn.LeakyReLU(0.2, inplace=True),
# (batch, 128, 16, 16) -> (batch, 256, 8, 8)
nn.Conv2d(128, 256, 4, 2, 1, bias=False),
nn.BatchNorm2d(256),
nn.LeakyReLU(0.2, inplace=True),
# (batch, 256, 8, 8) -> (batch, 512, 4, 4)
nn.Conv2d(256, 512, 4, 2, 1, bias=False),
nn.BatchNorm2d(512),
nn.LeakyReLU(0.2, inplace=True),
# (batch, 512, 4, 4) -> (batch, 1, 1, 1)
nn.Conv2d(512, 1, 4, 1, 0, bias=False),
nn.Sigmoid()
)
def forward(self, img):
validity = self.model(img)
return validity.view(-1, 1) # (batch, 1)로 flatten
설명
이것이 하는 일: DCGAN은 Generator에서 ConvTranspose2d(Deconvolution)로 이미지를 점진적으로 확대하고, Discriminator에서 Conv2d로 축소하면서 특징을 추출합니다. 이 과정에서 Batch Normalization이 학습을 안정화시킵니다.
첫 번째로, Generator는 100차원 노이즈 벡터를 (100, 1, 1) 형태로 변환한 뒤, ConvTranspose2d를 사용하여 점진적으로 공간 크기를 키웁니다. 4x4 -> 8x8 -> 16x16 -> 32x32 -> 64x64로 확대되면서 채널 수는 512 -> 256 -> 128 -> 64 -> 3으로 줄어듭니다.
왜 이렇게 할까요? 처음에는 추상적이고 많은 특징(512채널)을 표현하다가, 점차 구체적인 픽셀 정보(3채널 RGB)로 변환하는 것입니다.
각 ConvTranspose2d 뒤에 BatchNorm2d와 ReLU를 붙여서 학습을 안정화시킵니다. 두 번째로, Batch Normalization이 DCGAN의 성공에 결정적 역할을 합니다.
각 레이어의 출력을 평균 0, 분산 1로 정규화하여 internal covariate shift(레이어마다 입력 분포가 달라지는 문제)를 방지합니다. 이것이 없으면 학습 초반에 gradient가 폭발하거나 사라져서 제대로 학습이 되지 않습니다.
단, Generator의 출력층과 Discriminator의 입력층에는 BatchNorm을 적용하지 않습니다. 세 번째로, Discriminator는 Generator의 반대 과정을 수행합니다.
64x64 이미지를 받아서 stride=2인 Conv2d로 절반씩 크기를 줄이면서 채널은 늘립니다. MaxPooling 대신 Strided Convolution을 사용하는 이유는 네트워크가 스스로 downsampling 방법을 학습하게 하기 위해서입니다.
마지막 4x4 feature map을 1x1로 축소하여 진짜/가짜 판별 확률을 출력합니다. 여러분이 이 모델로 CelebA 얼굴 데이터셋을 학습시키면, 존재하지 않는 사람의 얼굴을 놀라울 정도로 사실적으로 생성할 수 있습니다.
또한 latent space를 탐색하면 안경을 쓴 얼굴에서 안경을 벗은 얼굴로 자연스럽게 변화하는 것도 관찰할 수 있습니다. ConvTranspose2d의 파라미터 (kernel=4, stride=2, padding=1)는 정확히 2배로 확대합니다.
수식으로는 output_size = (input_size - 1) * stride - 2 * padding + kernel입니다. 예: (4-1)2 - 21 + 4 = 8.
이 공식을 이해하면 원하는 해상도로 조정할 수 있습니다.
실전 팁
💡 가중치 초기화가 매우 중요합니다. DCGAN 논문에서는 평균 0, 표준편차 0.02인 정규분포로 초기화할 것을 권장합니다: model.apply(weights_init) 함수를 만들어 사용하세요.
💡 학습률은 Generator와 Discriminator 모두 0.0002, Adam optimizer의 beta1=0.5로 설정하면 안정적입니다. 일반적인 beta1=0.9보다 낮은 값을 쓰는 이유는 GAN 학습의 불안정성 때문입니다.
💡 Discriminator의 첫 번째 Conv2d에는 BatchNorm을 적용하지 마세요. 입력 이미지의 원래 분포를 유지해야 합니다.
💡 생성 이미지를 저장할 때는 (-1, 1) 범위를 (0, 1)로 변환하세요: imgs = (imgs + 1) / 2. 그래야 정상적으로 시각화됩니다.
💡 GPU 메모리가 부족하면 batch_size를 줄이되, BatchNorm의 통계량 계산을 위해 최소 16 이상은 유지하세요. 너무 작으면 BatchNorm이 제대로 작동하지 않습니다.
4. StyleGAN
시작하며
여러분이 사진 편집 앱에서 얼굴의 나이, 헤어스타일, 표정을 각각 독립적으로 조절할 수 있다면 얼마나 편리할까요? 기존 GAN은 이미지 전체를 한 번에 생성하기 때문에 특정 속성만 바꾸기가 거의 불가능했습니다.
노이즈 벡터를 조금만 바꿔도 이미지 전체가 완전히 달라졌습니다. 2018년 NVIDIA가 발표한 StyleGAN은 이 문제를 혁신적으로 해결했습니다.
잠재 공간을 재구성하고, 각 해상도별로 스타일을 주입하는 방식으로 이미지의 다양한 속성을 세밀하게 제어할 수 있게 되었습니다. StyleGAN으로 생성된 얼굴 이미지는 실제 사진과 거의 구별할 수 없을 정도로 사실적입니다.
thispersondoesnotexist.com 같은 웹사이트가 바로 StyleGAN을 활용한 대표적인 예시입니다.
개요
간단히 말해서, StyleGAN은 이미지의 스타일을 여러 레벨(거친 특징부터 세밀한 디테일까지)로 분리하여 제어할 수 있는 GAN 아키텍처입니다. 왜 스타일 기반 접근이 효과적일까요?
이미지는 계층적 구조를 가지고 있습니다. 낮은 해상도에서는 얼굴 형태, 포즈, 전체 구도 같은 거친 특징이 결정되고, 높은 해상도에서는 피부 질감, 머리카락 디테일, 눈동자 색상 같은 세밀한 요소가 추가됩니다.
StyleGAN은 이 원리를 반영하여 각 해상도마다 다른 스타일을 적용합니다. 예를 들어, 4x4에서는 얼굴 각도와 성별을 결정하고, 256x256에서는 주름과 화장 같은 디테일을 추가합니다.
기존 DCGAN은 노이즈 z를 직접 Generator에 입력했습니다. StyleGAN은 z를 먼저 Mapping Network에 통과시켜 중간 잠재 공간 w로 변환합니다.
그 다음 Adaptive Instance Normalization(AdaIN)을 통해 각 convolution layer에 스타일을 주입합니다. StyleGAN의 핵심 혁신은 세 가지입니다: (1) Mapping Network로 더 disentangled한 잠재 공간 생성, (2) AdaIN을 통한 스타일 주입, (3) 각 레이어마다 별도의 노이즈 추가로 stochastic variation(확률적 변화) 표현.
이를 통해 얼굴 구조는 유지하면서 주근깨 위치 같은 디테일만 바꿀 수 있습니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
class MappingNetwork(nn.Module):
"""Z space를 W space로 변환하여 더 disentangled한 표현 생성"""
def __init__(self, z_dim=512, w_dim=512, num_layers=8):
super().__init__()
layers = []
for i in range(num_layers):
layers.append(nn.Linear(z_dim if i == 0 else w_dim, w_dim))
layers.append(nn.LeakyReLU(0.2))
self.mapping = nn.Sequential(*layers)
def forward(self, z):
# z (batch, 512) -> w (batch, 512)
# 여러 층을 거치면서 더 disentangled한 표현으로 변환
return self.mapping(z)
class AdaIN(nn.Module):
"""Adaptive Instance Normalization: 스타일을 feature에 주입"""
def __init__(self, channels, w_dim=512):
super().__init__()
# W로부터 scale(gamma)과 bias(beta) 생성
self.instance_norm = nn.InstanceNorm2d(channels, affine=False)
self.style_scale = nn.Linear(w_dim, channels)
self.style_bias = nn.Linear(w_dim, channels)
def forward(self, x, w):
# x: feature map (batch, channels, H, W)
# w: style vector (batch, w_dim)
# Instance Normalization으로 정규화
x = self.instance_norm(x)
# Style로부터 scale과 bias 계산
scale = self.style_scale(w).unsqueeze(2).unsqueeze(3) # (batch, channels, 1, 1)
bias = self.style_bias(w).unsqueeze(2).unsqueeze(3)
# AdaIN: y = scale * x_normalized + bias
return scale * x + bias
class StyleBlock(nn.Module):
"""StyleGAN의 기본 블록: Conv + AdaIN + Noise"""
def __init__(self, in_channels, out_channels, w_dim=512):
super().__init__()
self.conv = nn.Conv2d(in_channels, out_channels, 3, padding=1)
self.adain = AdaIN(out_channels, w_dim)
# 각 픽셀마다 다른 노이즈를 추가할 수 있도록
self.noise_scale = nn.Parameter(torch.zeros(1))
self.activation = nn.LeakyReLU(0.2)
def forward(self, x, w, noise=None):
# 1. Convolution
x = self.conv(x)
# 2. Noise 추가 (stochastic variation을 위해)
if noise is not None:
x = x + self.noise_scale * noise
# 3. AdaIN으로 스타일 주입
x = self.adain(x, w)
# 4. Activation
return self.activation(x)
class SimpleStyleGAN(nn.Module):
"""간소화된 StyleGAN 구조"""
def __init__(self, z_dim=512, w_dim=512):
super().__init__()
self.mapping = MappingNetwork(z_dim, w_dim)
# Constant 입력 (학습 가능한 초기 feature)
self.constant = nn.Parameter(torch.randn(1, 512, 4, 4))
# 스타일 블록들
self.style_block1 = StyleBlock(512, 512, w_dim)
self.style_block2 = StyleBlock(512, 256, w_dim)
self.upsample = nn.Upsample(scale_factor=2, mode='bilinear')
self.to_rgb = nn.Conv2d(256, 3, 1)
def forward(self, z, noise1=None, noise2=None):
# 1. Z를 W로 매핑
w = self.mapping(z)
# 2. Constant feature에서 시작
x = self.constant.repeat(z.size(0), 1, 1, 1)
# 3. 첫 번째 스타일 블록 (4x4)
x = self.style_block1(x, w, noise1)
# 4. Upsampling (4x4 -> 8x8)
x = self.upsample(x)
# 5. 두 번째 스타일 블록 (8x8)
x = self.style_block2(x, w, noise2)
# 6. RGB 이미지로 변환
return torch.tanh(self.to_rgb(x))
설명
이것이 하는 일: StyleGAN은 기존 GAN과 달리 노이즈를 직접 사용하지 않고, Mapping Network를 거쳐 변환한 뒤 각 레이어마다 스타일로 주입합니다. 이를 통해 이미지 속성을 독립적으로 제어할 수 있습니다.
첫 번째로, Mapping Network가 512차원 노이즈 z를 8개의 완전연결층에 통과시켜 w로 변환합니다. 왜 굳이 이런 과정을 거칠까요?
랜덤 노이즈 z는 가우시안 분포를 따르기 때문에 각 차원이 서로 얽혀(entangled) 있습니다. 예를 들어 z의 한 차원을 바꾸면 나이와 성별이 동시에 변할 수 있습니다.
Mapping Network를 거치면 w 공간에서는 나이, 성별, 헤어스타일이 각각 독립적인 방향으로 분리됩니다(disentangled). 이것이 StyleGAN의 가장 큰 혁신입니다.
두 번째로, AdaIN(Adaptive Instance Normalization)이 스타일을 주입합니다. Instance Normalization으로 feature map을 평균 0, 분산 1로 정규화한 뒤, w로부터 계산한 scale과 bias를 곱하고 더합니다.
이 과정을 통해 "어떤 특징을 표현할지"(convolution이 결정)와 "그 특징의 스타일"(AdaIN이 결정)을 분리할 수 있습니다. 예를 들어 convolution은 "눈"이라는 특징을 추출하고, AdaIN은 "그 눈의 색상과 크기"를 결정합니다.
세 번째로, 각 블록마다 노이즈를 추가합니다. 이 노이즈는 주근깨 위치, 머리카락 배치 같은 확률적 디테일을 표현합니다.
noise_scale이라는 학습 가능한 파라미터로 노이즈의 영향력을 자동으로 조절합니다. 중요한 점은 이 노이즈가 w와 독립적이라는 것입니다.
같은 w로 여러 번 생성하면 전체 구도는 같지만 디테일이 다른 이미지들이 나옵니다. 네 번째로, Constant Input에서 시작합니다.
기존 GAN은 노이즈를 Generator에 입력했지만, StyleGAN은 학습 가능한 상수 텐서(4x4x512)에서 시작합니다. 모든 정보는 w를 통해 주입되므로 초기 입력은 고정되어도 됩니다.
이 방식이 더 안정적인 학습을 가능하게 합니다. 여러분이 이 모델을 사용하면 놀라운 일들을 할 수 있습니다: (1) Style Mixing - 한 이미지의 거친 특징과 다른 이미지의 세밀한 특징을 결합, (2) Attribute Editing - 잠재 공간에서 특정 방향으로 이동하여 나이, 성별, 표정 변경, (3) Interpolation - 두 얼굴 사이를 부드럽게 보간.
실제 StyleGAN2는 여기서 더 발전하여 progressive growing, path length regularization, skip connections 등을 추가했지만, 핵심 아이디어는 위 코드에 모두 담겨 있습니다.
실전 팁
💡 Truncation trick을 사용하면 생성 품질을 높일 수 있습니다. w를 평균값 쪽으로 당기세요: w = w_avg + psi * (w - w_avg), psi=0.7 정도가 적당합니다.
💡 Style mixing을 하려면 서로 다른 w를 각 레이어에 적용하세요. 낮은 해상도에는 w1, 높은 해상도에는 w2를 사용하면 구조는 w1, 디테일은 w2를 따릅니다.
💡 Mapping Network의 깊이(num_layers)가 중요합니다. 8개 레이어가 표준이며, 더 깊게 하면 disentanglement가 개선되지만 학습 시간이 늘어납니다.
💡 Noise를 제거하고 생성하면(noise=None) 더 부드럽지만 단조로운 이미지가 나옵니다. 노이즈는 자연스러운 불규칙성을 위해 필수입니다.
💡 W+ space를 사용하면 더 세밀한 제어가 가능합니다. 각 AdaIN 레이어마다 다른 w 벡터를 사용하는 방식입니다: w = [w1, w2, w3, ...] (레이어 수만큼).
5. Conditional_GAN
시작하며
여러분이 GAN으로 숫자 이미지를 생성한다고 상상해보세요. 기존 GAN은 0부터 9까지 무작위로 생성합니다.
하지만 만약 "지금은 3을 생성하고 싶어"라고 지정할 수 있다면 훨씬 실용적이지 않을까요? 특히 특정 카테고리의 이미지가 필요한 실무에서는 이런 제어 능력이 필수적입니다.
2014년 Mirza와 Osindero가 제안한 Conditional GAN(cGAN)은 이 문제를 간단하면서도 효과적으로 해결했습니다. Generator와 Discriminator에 조건 정보(레이블)를 추가 입력으로 제공하는 것만으로 원하는 클래스의 이미지를 생성할 수 있게 되었습니다.
이 아이디어는 이후 Pix2Pix(이미지 변환), CycleGAN(도메인 변환), Text-to-Image 같은 다양한 conditional 생성 모델의 기반이 되었습니다. 조건을 어떻게 주느냐에 따라 무한한 응용이 가능합니다.
개요
간단히 말해서, Conditional GAN은 레이블이나 텍스트 같은 조건 정보를 함께 입력받아, 원하는 속성을 가진 이미지를 생성할 수 있는 GAN입니다. 왜 조건부 생성이 중요할까요?
실제 응용에서는 무작위 이미지보다 특정 요구사항을 만족하는 이미지가 필요한 경우가 대부분입니다. 의료 영상에서 특정 질병의 사례를 생성하거나, 게임에서 특정 스타일의 캐릭터를 만들거나, 디자인 작업에서 특정 색상 조합의 제품을 시각화할 때 조건부 생성이 필수입니다.
예를 들어, 패션 산업에서 "빨간색 원피스"를 생성하려면 색상과 의류 타입을 조건으로 제공해야 합니다. 기존 GAN은 p(x)만 학습했습니다.
Conditional GAN은 p(x|c), 즉 "조건 c가 주어졌을 때의 이미지 분포"를 학습합니다. 수학적으로는 간단한 확장이지만, 실용성은 엄청나게 향상됩니다.
cGAN의 핵심 원리: Generator는 (노이즈 z, 조건 c)를 입력받아 이미지를 생성하고, Discriminator는 (이미지 x, 조건 c)를 입력받아 "이 이미지가 진짜이면서 조건 c에 부합하는지"를 판별합니다. Discriminator가 조건도 함께 확인하기 때문에, Generator는 조건에 맞는 이미지를 생성하도록 학습됩니다.
코드 예제
import torch
import torch.nn as nn
class ConditionalGenerator(nn.Module):
"""조건 정보를 받아서 해당 클래스의 이미지 생성"""
def __init__(self, latent_dim=100, num_classes=10, img_size=28):
super().__init__()
self.latent_dim = latent_dim
self.num_classes = num_classes
# 레이블을 embedding으로 변환 (one-hot보다 효율적)
self.label_embedding = nn.Embedding(num_classes, num_classes)
# Generator: (z + label_embedding)을 이미지로 변환
input_dim = latent_dim + num_classes
self.model = nn.Sequential(
nn.Linear(input_dim, 128),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(128),
nn.Linear(128, 256),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(256),
nn.Linear(256, 512),
nn.LeakyReLU(0.2),
nn.BatchNorm1d(512),
nn.Linear(512, img_size * img_size),
nn.Tanh() # -1 ~ 1 범위
)
def forward(self, z, labels):
# z: (batch, latent_dim), labels: (batch,)
# 레이블을 embedding 벡터로 변환
label_embed = self.label_embedding(labels) # (batch, num_classes)
# 노이즈와 레이블 결합
gen_input = torch.cat([z, label_embed], dim=1) # (batch, latent_dim + num_classes)
# 이미지 생성
img = self.model(gen_input)
return img.view(img.size(0), 1, 28, 28)
class ConditionalDiscriminator(nn.Module):
"""이미지와 레이블을 받아서 진짜인지 & 레이블이 맞는지 판별"""
def __init__(self, num_classes=10, img_size=28):
super().__init__()
self.num_classes = num_classes
# 레이블을 이미지 크기와 같은 차원으로 embedding
self.label_embedding = nn.Embedding(num_classes, img_size * img_size)
# Discriminator: (이미지 + label_embedding)을 입력
input_dim = img_size * img_size + img_size * img_size
self.model = nn.Sequential(
nn.Linear(input_dim, 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, labels):
# img: (batch, 1, 28, 28), labels: (batch,)
# 이미지 flatten
img_flat = img.view(img.size(0), -1) # (batch, 784)
# 레이블을 embedding으로 변환
label_embed = self.label_embedding(labels) # (batch, 784)
# 이미지와 레이블 결합
disc_input = torch.cat([img_flat, label_embed], dim=1) # (batch, 1568)
# 판별
return self.model(disc_input)
# 사용 예시
def generate_specific_digit(generator, digit, num_samples=16):
"""특정 숫자를 생성하는 함수"""
generator.eval()
with torch.no_grad():
# 노이즈 생성
z = torch.randn(num_samples, 100)
# 원하는 숫자의 레이블 생성
labels = torch.full((num_samples,), digit, dtype=torch.long)
# 이미지 생성
fake_imgs = generator(z, labels)
return fake_imgs
설명
이것이 하는 일: Conditional GAN은 노이즈 벡터에 조건 정보를 결합하여 Generator에 전달하고, Discriminator도 같은 조건 정보를 받아 "이미지가 진짜이면서 조건과 일치하는지"를 판별합니다. 이를 통해 원하는 속성의 이미지만 선택적으로 생성할 수 있습니다.
첫 번째로, Generator는 레이블을 Embedding Layer에 통과시킵니다. 왜 one-hot encoding 대신 embedding을 쓸까요?
One-hot은 [0,0,0,1,0,0,0,0,0,0] 같은 sparse 벡터라서 비효율적이고, 클래스 간 관계를 표현할 수 없습니다. Embedding은 학습 가능한 dense 벡터로, 예를 들어 숫자 '6'과 '8'이 비슷한 형태라는 것을 벡터 공간에서 가까운 위치로 자동으로 학습합니다.
이 embedding 벡터를 노이즈 z와 concatenate하여 (latent_dim + num_classes) 차원의 입력을 만듭니다. 두 번째로, 결합된 입력이 여러 완전연결층을 거치면서 점차 확장됩니다: 110차원 -> 128 -> 256 -> 512 -> 784차원.
각 레이어 사이에 LeakyReLU와 BatchNorm1d를 넣어서 학습을 안정화합니다. 최종적으로 Tanh를 통과하여 -1~1 범위의 784차원 벡터가 나오고, 이를 (1, 28, 28) 이미지로 reshape합니다.
중요한 점은 레이블 정보가 모든 레이어를 거쳐 전파되면서 이미지 생성 과정에 영향을 준다는 것입니다. 세 번째로, Discriminator도 이미지와 레이블을 함께 받습니다.
레이블을 img_size * img_size 차원으로 embedding하여 이미지와 같은 크기로 만든 뒤 concatenate합니다. 왜 이렇게 할까요?
Discriminator가 "이 이미지는 진짜 같은데, 레이블 3이라고 했는데 실제로는 8처럼 보이네"라고 판단할 수 있어야 하기 때문입니다. 이미지와 레이블 정보를 함께 분석하여 둘의 일치 여부를 판별합니다.
네 번째로, 학습 과정에서 Discriminator는 세 가지를 구분하도록 학습됩니다: (1) 진짜 이미지 + 올바른 레이블 = 1, (2) 진짜 이미지 + 잘못된 레이블 = 0, (3) 가짜 이미지 + 아무 레이블 = 0. 이렇게 학습하면 Generator는 노이즈로부터 이미지를 만드는 것뿐만 아니라, 주어진 레이블에 부합하는 이미지를 만들어야 Discriminator를 속일 수 있습니다.
여러분이 이 모델을 학습시킨 후 generate_specific_digit(generator, 7, 16)을 호출하면, 16개의 서로 다른 '7' 이미지가 생성됩니다. 같은 레이블이지만 노이즈가 다르기 때문에 필기체, 각도, 두께 등이 모두 다릅니다.
이것이 cGAN의 강점입니다: 다양성은 유지하면서 제어 가능합니다. 실제 응용에서는 레이블뿐만 아니라 텍스트(text-to-image), 세그멘테이션 맵(semantic image synthesis), 스케치(sketch-to-photo) 등 다양한 형태의 조건을 사용할 수 있습니다.
핵심은 조건 정보를 적절히 인코딩해서 Generator와 Discriminator에 전달하는 것입니다.
실전 팁
💡 레이블이 불균형하면(예: 클래스 0은 1000개, 클래스 9는 100개) Generator가 편향됩니다. 학습 시 클래스별로 균등하게 샘플링하거나, class-balanced loss를 사용하세요.
💡 Discriminator에 잘못된 레이블을 제공하는 negative sampling을 추가하면 성능이 향상됩니다: 진짜 이미지 + 랜덤 레이블을 가짜로 판별하도록 학습.
💡 Projection Discriminator를 사용하면 더 효과적입니다: 이미지 feature와 label embedding의 내적(dot product)을 계산하여 판별. 간단한 concatenation보다 성능이 좋습니다.
💡 다중 조건(예: 숫자 + 색상)을 사용하려면 각 조건을 별도로 embedding한 뒤 모두 concatenate하세요. 조건이 많아질수록 더 정교한 제어가 가능합니다.
💡 생성 품질을 평가할 때는 클래스별로 FID를 측정하세요. 전체 FID는 좋아도 특정 클래스만 잘 생성되고 다른 클래스는 품질이 낮을 수 있습니다.
6. Diffusion_Model
시작하며
여러분이 잉크 한 방울을 물에 떨어뜨리면 점차 퍼지면서 물 전체가 균일하게 변하는 것을 본 적 있나요? 이 과정을 역으로 되돌릴 수 있다면, 즉 균일한 물에서 시작해서 점차 명확한 잉크 방울을 복원할 수 있다면 어떨까요?
2020년 등장한 Diffusion Model은 바로 이런 아이디어를 구현했습니다. 이미지에 노이즈를 조금씩 추가하는 forward process와, 노이즈에서 이미지를 복원하는 reverse process를 학습합니다.
GAN이 한 번에 이미지를 생성하는 것과 달리, Diffusion은 수백~수천 단계에 걸쳐 점진적으로 이미지를 만들어냅니다. 놀랍게도 이 방법은 GAN보다 훨씬 안정적으로 학습되고, 더 다양하고 고품질의 이미지를 생성합니다.
DALL-E 2, Stable Diffusion, Midjourney 같은 최신 이미지 생성 모델이 모두 Diffusion 기반입니다. GAN의 시대에서 Diffusion의 시대로 패러다임이 완전히 바뀐 것입니다.
개요
간단히 말해서, Diffusion Model은 깨끗한 이미지에 노이즈를 단계적으로 추가하는 과정을 학습한 뒤, 그 과정을 역으로 수행하여 순수 노이즈에서 이미지를 생성하는 모델입니다. 왜 이 접근법이 혁신적일까요?
GAN은 한 번의 forward pass로 이미지를 생성하기 때문에 학습이 불안정하고, mode collapse 같은 문제가 자주 발생합니다. Diffusion은 매우 작은 변화를 수천 번 반복하기 때문에 각 단계의 목표가 간단하고 명확합니다.
"매우 노이즈가 많은 이미지"를 "조금 덜 노이즈가 많은 이미지"로 바꾸는 것만 학습하면 됩니다. 예를 들어, 얼굴 이미지 생성에서 GAN은 한 번에 완벽한 얼굴을 만들어야 하지만, Diffusion은 1000단계에 걸쳐 점차 윤곽이 드러나게 합니다.
기존 생성 모델들과 비교하면: VAE는 흐릿한 이미지, GAN은 학습 불안정성과 mode collapse, Flow 모델은 아키텍처 제약이 문제였습니다. Diffusion은 이 모든 문제를 해결하면서도 품질과 다양성을 모두 달성했습니다.
Diffusion의 핵심 원리: Forward process에서는 q(x_t | x_{t-1}) = N(√α_t · x_{t-1}, (1-α_t)I) 로 가우시안 노이즈를 점진적으로 추가합니다. Reverse process에서는 신경망 p_θ(x_{t-1} | x_t)가 노이즈를 제거하는 방법을 학습합니다.
수학적으로는 복잡해 보이지만, 실제로는 "각 시간 단계에서 추가된 노이즈를 예측"하는 간단한 문제로 귀결됩니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
class SimpleDiffusion:
"""간소화된 Diffusion Model의 핵심 로직"""
def __init__(self, num_timesteps=1000, beta_start=0.0001, beta_end=0.02):
# 노이즈 스케줄 정의 (선형 스케줄)
self.num_timesteps = num_timesteps
self.betas = torch.linspace(beta_start, beta_end, num_timesteps)
self.alphas = 1.0 - self.betas
self.alphas_cumprod = torch.cumprod(self.alphas, dim=0) # α_bar_t = ∏(1-β_i)
self.sqrt_alphas_cumprod = torch.sqrt(self.alphas_cumprod)
self.sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - self.alphas_cumprod)
def forward_diffusion(self, x0, t):
"""
Forward process: x_0에서 x_t로 노이즈 추가
q(x_t | x_0) = N(√α_bar_t · x_0, (1 - α_bar_t)I)
"""
# t 시간 단계의 α_bar 가져오기
sqrt_alpha_bar_t = self.sqrt_alphas_cumprod[t].view(-1, 1, 1, 1)
sqrt_one_minus_alpha_bar_t = self.sqrt_one_minus_alphas_cumprod[t].view(-1, 1, 1, 1)
# 표준정규분포에서 노이즈 샘플링
noise = torch.randn_like(x0)
# Reparameterization: x_t = √α_bar_t · x_0 + √(1-α_bar_t) · ε
x_t = sqrt_alpha_bar_t * x0 + sqrt_one_minus_alpha_bar_t * noise
return x_t, noise
def reverse_diffusion_step(self, model, x_t, t):
"""
Reverse process 한 단계: x_t에서 x_{t-1}로 노이즈 제거
"""
# 모델이 노이즈를 예측
predicted_noise = model(x_t, t)
# x_t에서 노이즈를 빼서 x_0를 추정
alpha_bar_t = self.alphas_cumprod[t].view(-1, 1, 1, 1)
sqrt_alpha_bar_t = torch.sqrt(alpha_bar_t)
sqrt_one_minus_alpha_bar_t = torch.sqrt(1.0 - alpha_bar_t)
# x_0 예측: x_0 = (x_t - √(1-α_bar_t) · ε) / √α_bar_t
x0_pred = (x_t - sqrt_one_minus_alpha_bar_t * predicted_noise) / sqrt_alpha_bar_t
# x_{t-1} 계산 (simplified, 실제로는 더 복잡한 수식)
if t[0] > 0:
# 약간의 노이즈를 추가 (stochasticity 유지)
noise = torch.randn_like(x_t)
alpha_t = self.alphas[t].view(-1, 1, 1, 1)
x_t_minus_1 = torch.sqrt(alpha_t) * x0_pred + torch.sqrt(1 - alpha_t) * noise
else:
x_t_minus_1 = x0_pred
return x_t_minus_1
class SimpleUNet(nn.Module):
"""간소화된 U-Net: 노이즈를 예측하는 신경망"""
def __init__(self, in_channels=3, time_emb_dim=128):
super().__init__()
# Time embedding: 시간 t를 벡터로 인코딩
self.time_mlp = nn.Sequential(
nn.Linear(1, time_emb_dim),
nn.SiLU(),
nn.Linear(time_emb_dim, time_emb_dim)
)
# Downsampling
self.down1 = nn.Conv2d(in_channels, 64, 3, padding=1)
self.down2 = nn.Conv2d(64, 128, 3, padding=1, stride=2)
# Bottleneck
self.bottleneck = nn.Conv2d(128, 128, 3, padding=1)
# Upsampling
self.up1 = nn.ConvTranspose2d(128, 64, 4, stride=2, padding=1)
self.up2 = nn.Conv2d(64, in_channels, 3, padding=1)
def forward(self, x, t):
# Time embedding
t_emb = self.time_mlp(t.float().view(-1, 1)) # (batch, time_emb_dim)
# U-Net forward (simplified)
d1 = F.silu(self.down1(x))
d2 = F.silu(self.down2(d1))
# Time embedding을 bottleneck에 주입 (실제로는 더 정교하게)
b = F.silu(self.bottleneck(d2))
u1 = F.silu(self.up1(b))
u2 = self.up2(u1)
return u2 # 예측된 노이즈
# 학습 과정
def train_diffusion(model, diffusion, images, optimizer):
"""Diffusion Model 학습 한 스텝"""
optimizer.zero_grad()
batch_size = images.size(0)
# 랜덤한 시간 단계 선택 (0 ~ T-1)
t = torch.randint(0, diffusion.num_timesteps, (batch_size,))
# Forward diffusion: 이미지에 노이즈 추가
x_t, true_noise = diffusion.forward_diffusion(images, t)
# 모델이 노이즈 예측
predicted_noise = model(x_t, t)
# Loss: 예측 노이즈와 실제 노이즈의 차이 (MSE)
loss = F.mse_loss(predicted_noise, true_noise)
loss.backward()
optimizer.step()
return loss.item()
설명
이것이 하는 일: Diffusion Model은 두 가지 과정으로 구성됩니다. Forward process는 깨끗한 이미지 x_0에 T번(보통 1000번)에 걸쳐 가우시안 노이즈를 추가하여 완전한 노이즈 x_T로 만듭니다.
Reverse process는 신경망이 이 과정을 역으로 수행하여 x_T에서 x_0를 복원합니다. 첫 번째로, 노이즈 스케줄이 핵심입니다.
Beta 값(β_t)을 0.0001에서 0.02까지 선형으로 증가시킵니다. 이것은 각 단계에서 추가할 노이즈의 양을 결정합니다.
왜 선형 스케줄을 쓸까요? 처음에는 작은 노이즈를 추가해서 이미지 구조를 유지하고, 나중에는 큰 노이즈를 추가하여 완전히 파괴하기 위해서입니다.
alphas_cumprod(α_bar_t)는 ∏(1-β_i)로, "0단계부터 t단계까지 누적된 신호 보존 비율"을 나타냅니다. 이 값이 있으면 한 번에 임의의 시간 t로 점프할 수 있어서 학습이 효율적입니다.
두 번째로, forward_diffusion 함수는 reparameterization trick을 사용합니다: x_t = √α_bar_t · x_0 + √(1-α_bar_t) · ε. 이 공식의 의미는 "원본 이미지의 α_bar_t 비율만큼 유지하고, 나머지는 노이즈로 채운다"는 것입니다.
예를 들어 t=500일 때 α_bar_500=0.5라면, 원본 70%(√0.5≈0.7)와 노이즈 70%를 섞습니다. 이 방식 덕분에 t=0에서 t=1000까지 순차적으로 노이즈를 추가하지 않고, 바로 원하는 시간 단계로 점프할 수 있습니다.
세 번째로, 학습 과정이 매우 간단합니다. 배치의 각 이미지마다 랜덤하게 시간 t를 선택하고, forward diffusion으로 x_t와 추가된 노이즈를 얻습니다.
그 다음 모델이 x_t를 보고 노이즈를 예측하게 하고, 실제 노이즈와의 MSE를 최소화합니다. 즉, "노이즈가 섞인 이미지를 주면 그 노이즈가 뭐였는지 맞춰봐"라는 과제를 푸는 것입니다.
이것이 전부입니다! GAN처럼 두 개의 네트워크를 경쟁시킬 필요도 없고, VAE처럼 복잡한 KL divergence를 계산할 필요도 없습니다.
네 번째로, 생성(sampling) 과정은 학습의 역입니다. x_T = N(0, I)에서 시작하여, 모델이 예측한 노이즈를 빼면서 x_{T-1}, x_{T-2}, ..., x_0로 점진적으로 복원합니다.
각 단계에서 약간의 랜덤 노이즈를 추가하는 것(t>0일 때)이 중요합니다. 이것이 없으면 deterministic하게 하나의 이미지만 생성되지만, 있으면 같은 초기 노이즈에서도 매번 다른 이미지가 나옵니다.
여러분이 이 모델을 학습시키면, 생성 과정을 시각화할 때 매우 흥미롭습니다. 처음에는 완전한 노이즈였다가, 100단계쯤 지나면 흐릿한 윤곽이 보이고, 500단계에서는 색상과 대략적인 형태가 드러나며, 900단계에서는 거의 완성된 이미지가 나타납니다.
마치 안개 속에서 점차 사물이 모습을 드러내는 것 같습니다. Diffusion Model의 단점은 생성 속도입니다.
GAN은 한 번의 forward pass(0.01초)로 생성하지만, Diffusion은 1000번의 forward pass(10초 이상)가 필요합니다. 이를 해결하기 위해 DDIM(Denoising Diffusion Implicit Models) 같은 가속 기법이 개발되었습니다.
실전 팁
💡 노이즈 스케줄이 품질에 큰 영향을 줍니다. 선형 대신 cosine schedule을 사용하면 학습 초반 안정성이 향상됩니다: alphas_cumprod = cos((t/T + s) / (1 + s) * π/2)^2
💡 Prediction target을 바꿀 수 있습니다: 노이즈 ε 대신 x_0를 직접 예측하거나, v-prediction (ε와 x_0의 조합)을 예측하는 방법도 있습니다. 각각 장단점이 있으니 실험해보세요.
💡 Classifier-free guidance를 사용하면 조건부 생성의 품질을 크게 향상시킬 수 있습니다. 조건이 있을 때와 없을 때의 예측을 혼합합니다: ε = ε_uncond + w * (ε_cond - ε_uncond)
💡 학습 시 EMA(Exponential Moving Average)를 사용하세요. 모델 가중치의 이동 평균을 저장하면 생성 품질이 향상됩니다: ema_model = 0.999 * ema_model + 0.001 * model
💡 샘플링 단계를 줄이려면 DDIM을 사용하세요. 1000단계 대신 50단계만으로도 비슷한 품질을 얻을 수 있어 생성 속도가 20배 빨라집니다.
7. DDPM
시작하며
여러분이 Diffusion Model의 개념을 이해했다면, 이제 실제로 어떻게 구현하고 학습시키는지 궁금하실 겁니다. 2020년 Ho et al.이 발표한 DDPM(Denoising Diffusion Probabilistic Models)은 Diffusion의 이론을 실용적인 알고리즘으로 완성한 획기적인 논문입니다.
DDPM 이전에도 Diffusion 아이디어는 있었지만, 학습이 어렵고 품질이 낮아서 주목받지 못했습니다. DDPM은 간단한 목적 함수(denoising score matching)와 효율적인 학습 방법을 제시하여, Diffusion을 실용화시킨 중요한 이정표가 되었습니다.
이 논문의 핵심은 "복잡한 확률 분포를 학습하는 문제"를 "간단한 노이즈 제거 문제"로 바꾼 것입니다. 수학적으로는 정교하지만, 구현은 놀라울 정도로 간단합니다.
이것이 DDPM의 아름다움입니다.
개요
간단히 말해서, DDPM은 Diffusion Model의 학습과 샘플링을 구체적인 알고리즘으로 정의하고, denoising을 통한 효율적인 학습 방법을 제시한 모델입니다. 왜 DDPM이 성공했을까요?
기존 likelihood-based 모델들(VAE, Flow)은 데이터의 정확한 확률 분포를 모델링하려고 했습니다. 하지만 고차원 이미지의 정확한 분포를 학습하는 것은 매우 어렵습니다.
DDPM은 발상을 전환했습니다: "노이즈가 섞인 이미지를 깨끗하게 만드는 방법"만 학습하면, 그것을 반복 적용하여 순수 노이즈에서 이미지를 생성할 수 있다는 것입니다. 예를 들어, 얼굴 사진에 먼지가 묻었을 때 먼지를 제거하는 방법을 배우면, 완전히 먼지로 뒤덮인 사진도 여러 번 청소하여 복원할 수 있는 원리입니다.
기존 Diffusion 연구와 DDPM의 차이: 이전 연구들은 ELBO(Evidence Lower Bound)를 최적화하려고 했지만 구현이 복잡했습니다. DDPM은 간소화된 목적 함수를 제안했습니다: L_simple = E[||ε - ε_θ(x_t, t)||²].
이론적으로는 ELBO의 일부를 무시한 것이지만, 실제로는 이게 더 좋은 결과를 냅니다. DDPM의 핵심 설계 선택: (1) 분산 스케줄은 선형, (2) 네트워크는 U-Net, (3) 시간 정보는 sinusoidal position encoding, (4) 손실 함수는 단순 MSE (가중치 없음).
이 네 가지만 지키면 안정적으로 학습됩니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class SinusoidalPositionEmbeddings(nn.Module):
"""시간 t를 연속적인 벡터로 인코딩 (Transformer의 positional encoding과 유사)"""
def __init__(self, dim):
super().__init__()
self.dim = dim
def forward(self, time):
device = time.device
half_dim = self.dim // 2
# 주파수 계산: 10000^(-2i/d)
embeddings = math.log(10000) / (half_dim - 1)
embeddings = torch.exp(torch.arange(half_dim, device=device) * -embeddings)
# time * 주파수
embeddings = time[:, None] * embeddings[None, :]
# sin과 cos 결합
embeddings = torch.cat((embeddings.sin(), embeddings.cos()), dim=-1)
return embeddings
class DDPMScheduler:
"""DDPM의 노이즈 스케줄과 샘플링 로직"""
def __init__(self, num_timesteps=1000, beta_start=0.0001, beta_end=0.02):
self.num_timesteps = num_timesteps
# Linear schedule: β_t를 선형으로 증가
self.betas = torch.linspace(beta_start, beta_end, num_timesteps)
self.alphas = 1.0 - self.betas
self.alphas_cumprod = torch.cumprod(self.alphas, dim=0)
# 이전 시간 단계의 α_bar (샘플링에 필요)
self.alphas_cumprod_prev = F.pad(self.alphas_cumprod[:-1], (1, 0), value=1.0)
# 샘플링에 필요한 사전 계산 값들
self.sqrt_alphas_cumprod = torch.sqrt(self.alphas_cumprod)
self.sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - self.alphas_cumprod)
self.sqrt_recip_alphas = torch.sqrt(1.0 / self.alphas)
# Posterior variance: β_tilde_t = (1 - α_bar_{t-1}) / (1 - α_bar_t) * β_t
self.posterior_variance = (
self.betas * (1.0 - self.alphas_cumprod_prev) / (1.0 - self.alphas_cumprod)
)
def q_sample(self, x_start, t, noise=None):
"""Forward diffusion: q(x_t | x_0)에서 샘플링"""
if noise is None:
noise = torch.randn_like(x_start)
sqrt_alpha_bar = self.sqrt_alphas_cumprod[t][:, None, None, None]
sqrt_one_minus_alpha_bar = self.sqrt_one_minus_alphas_cumprod[t][:, None, None, None]
return sqrt_alpha_bar * x_start + sqrt_one_minus_alpha_bar * noise
def p_sample(self, model, x, t, t_index):
"""Reverse diffusion 한 단계: p(x_{t-1} | x_t)"""
# 모델이 노이즈 예측
predicted_noise = model(x, t)
# x_0 예측 추출
alpha_bar = self.alphas_cumprod[t][:, None, None, None]
alpha = self.alphas[t][:, None, None, None]
sqrt_one_minus_alpha_bar = self.sqrt_one_minus_alphas_cumprod[t][:, None, None, None]
sqrt_recip_alpha = self.sqrt_recip_alphas[t][:, None, None, None]
# DDPM 수식: x_{t-1} = 1/√α_t * (x_t - (1-α_t)/√(1-α_bar_t) * ε_θ) + σ_t * z
model_mean = sqrt_recip_alpha * (
x - (1 - alpha) / sqrt_one_minus_alpha_bar * predicted_noise
)
if t_index == 0:
# t=0일 때는 노이즈를 추가하지 않음
return model_mean
else:
posterior_variance = self.posterior_variance[t][:, None, None, None]
noise = torch.randn_like(x)
return model_mean + torch.sqrt(posterior_variance) * noise
@torch.no_grad()
def p_sample_loop(self, model, shape):
"""전체 reverse diffusion: x_T -> x_0"""
device = next(model.parameters()).device
# T 단계: 순수 노이즈에서 시작
img = torch.randn(shape, device=device)
imgs = []
# T-1, T-2, ..., 0 단계로 점진적으로 복원
for i in reversed(range(0, self.num_timesteps)):
t = torch.full((shape[0],), i, device=device, dtype=torch.long)
img = self.p_sample(model, img, t, i)
imgs.append(img.cpu())
return imgs
class SimpleUNetForDDPM(nn.Module):
"""DDPM을 위한 간단한 U-Net (시간 정보 포함)"""
def __init__(self, in_channels=3, out_channels=3, time_emb_dim=256):
super().__init__()
# Time embedding
self.time_mlp = nn.Sequential(
SinusoidalPositionEmbeddings(time_emb_dim),
nn.Linear(time_emb_dim, time_emb_dim),
nn.GELU(),
nn.Linear(time_emb_dim, time_emb_dim)
)
# U-Net 구조 (매우 간소화됨)
self.down1 = nn.Conv2d(in_channels, 64, 3, padding=1)
self.down2 = nn.Conv2d(64, 128, 3, padding=1)
self.bot = nn.Conv2d(128, 128, 3, padding=1)
self.up1 = nn.ConvTranspose2d(128, 64, 3, padding=1)
self.up2 = nn.Conv2d(64, out_channels, 3, padding=1)
# Time embedding을 feature에 주입하기 위한 linear
self.time_proj = nn.Linear(time_emb_dim, 128)
def forward(self, x, t):
# Time embedding 계산
t_emb = self.time_mlp(t) # (batch, time_emb_dim)
# Downsampling
d1 = F.relu(self.down1(x))
d2 = F.relu(self.down2(d1))
# Bottleneck + Time embedding 주입
b = self.bot(d2)
t_proj = self.time_proj(t_emb)[:, :, None, None] # (batch, 128, 1, 1)
b = F.relu(b + t_proj) # Time 정보를 feature에 더함
# Upsampling
u1 = F.relu(self.up1(b))
u2 = self.up2(u1)
return u2 # 예측된 노이즈
# 학습 함수
def train_ddpm_step(model, scheduler, images, optimizer):
"""DDPM 학습 한 스텝"""
optimizer.zero_grad()
batch_size = images.size(0)
device = images.device
# 1. 랜덤 시간 단계 샘플링
t = torch.randint(0, scheduler.num_timesteps, (batch_size,), device=device).long()
# 2. 노이즈 샘플링
noise = torch.randn_like(images)
# 3. Forward diffusion
x_noisy = scheduler.q_sample(images, t, noise=noise)
# 4. 노이즈 예측
predicted_noise = model(x_noisy, t)
# 5. Simple loss: MSE between true noise and predicted noise
loss = F.mse_loss(predicted_noise, noise)
loss.backward()
optimizer.step()
return loss.item()
설명
이것이 하는 일: DDPM은 세 가지 핵심 컴포넌트로 구성됩니다: (1) Noise Schedule이 각 단계의 노이즈 양을 결정, (2) U-Net이 노이즈를 예측, (3) Sampler가 T단계에 걸쳐 점진적으로 이미지를 복원합니다. 첫 번째로, Sinusoidal Position Embedding이 시간 정보를 인코딩합니다.
왜 단순히 t를 숫자로 주지 않을까요? 신경망은 연속적인 값의 미묘한 차이를 구분하기 어렵습니다.
t=100과 t=101의 차이를 제대로 인식하지 못할 수 있습니다. Sinusoidal encoding은 각 시간을 고유한 주파수 패턴으로 변환합니다: sin(t/10000^(0/128)), cos(t/10000^(0/128)), sin(t/10000^(1/128)), ...
이렇게 하면 t=100과 t=101이 벡터 공간에서 명확히 구분되면서도, 가까운 시간끼리는 유사한 벡터를 갖게 됩니다. Transformer의 positional encoding과 같은 원리입니다.
두 번째로, DDPMScheduler가 모든 필요한 값을 사전 계산합니다. alphas_cumprod는 ∏(1-β_i)인데, 이것만 있으면 어떤 시간 t로도 바로 점프할 수 있습니다.
posterior_variance는 reverse process에서 추가할 노이즈의 양입니다. DDPM 논문의 핵심 수학을 모두 이 클래스에 캡슐화했습니다.
q_sample은 forward diffusion(한 단계가 아니라 0→t를 한 번에), p_sample은 reverse diffusion(t→t-1 한 단계)을 수행합니다. 세 번째로, p_sample의 수식을 자세히 봅시다: model_mean = 1/√α_t * (x_t - (1-α_t)/√(1-α_bar_t) * ε_θ).
이것은 Bayes' rule과 Gaussian 분포의 성질로 유도된 식입니다. 직관적으로는 "x_t에서 예측된 노이즈를 적절히 스케일링해서 빼면, x_{t-1}의 평균을 얻을 수 있다"는 의미입니다.
그 다음 posterior_variance만큼의 노이즈를 추가하여 확률적 샘플링을 수행합니다. 이 노이즈가 없으면 모든 생성 이미지가 똑같아집니다.
네 번째로, p_sample_loop가 실제 생성 과정입니다. torch.randn으로 순수 노이즈 x_T를 만들고, for i in reversed(range(0, 1000))으로 999, 998, ..., 1, 0 순서로 p_sample을 호출합니다.
각 단계에서 조금씩 노이즈가 제거되며, 최종적으로 x_0(깨끗한 이미지)를 얻습니다. 중간 결과를 저장하면 노이즈에서 이미지로 변해가는 과정을 동영상으로 만들 수 있습니다.
다섯 번째로, 학습 과정이 매우 간단합니다. (1) 배치에서 이미지를 가져오고, (2) 각 이미지마다 랜덤 시간 t를 선택하고, (3) forward diffusion으로 x_t를 만들고, (4) 모델이 노이즈를 예측하고, (5) 실제 노이즈와의 MSE를 계산.
끝입니다. Adversarial loss도 없고, KL divergence도 없습니다.
단순한 regression 문제입니다. 여러분이 DDPM을 학습시키면, 초반에는 모델이 노이즈를 거의 예측하지 못해서 생성 이미지가 노이즈 그대로입니다.
하지만 몇 만 iteration 후에는 점차 이미지가 나타나기 시작하고, 충분히 학습하면 실제 데이터와 구별할 수 없는 고품질 이미지를 생성합니다. DDPM의 문제점은 샘플링 속도입니다.
1000번의 denoising step이 필요하므로 이미지 하나 생성에 수십 초가 걸립니다. 이를 해결하기 위해 DDIM, DPM-Solver 같은 가속 샘플러가 개발되었습니다.
실전 팁
💡 학습 시 이미지를 [-1, 1]로 정규화하세요: images = images * 2.0 - 1.0. 모델 출력도 같은 범위여야 노이즈 예측이 정확합니다.
💡 Cosine schedule이 linear schedule보다 좋은 경우가 많습니다. 특히 고해상도 이미지에서는 초반에 너무 많은 노이즈를 추가하지 않는 것이 중요합니다.
💡 Self-conditioning을 사용하면 샘플링 품질을 향상시킬 수 있습니다. 이전 step의 x_0 예측을 현재 step의 입력으로 추가합니다.
💡 Classifier-free guidance를 위해 조건(레이블, 텍스트)을 20% 확률로 null로 대체하여 학습하세요. 추론 시 조건부/무조건부 예측을 혼합하면 품질이 크게 향상됩니다.
💡 U-Net에 attention layer를 추가하면 품질이 향상됩니다. 특히 해상도가 16x16, 8x8인 레벨에 self-attention을 넣으면 장거리 의존성을 잘 포착합니다.
8. Latent_Diffusion
시작하며
여러분이 DDPM으로 고해상도 이미지(512x512)를 생성하려고 하면 큰 문제에 부딪힙니다. GPU 메모리가 부족하고, 학습 시간이 며칠씩 걸립니다.
왜일까요? 512x512x3 = 786,432 차원의 공간에서 diffusion을 수행하기 때문입니다.
이렇게 고차원 공간에서 작동하는 것은 매우 비효율적입니다. 2021년 Stable Diffusion의 기반이 된 Latent Diffusion Model(LDM)이 이 문제를 혁신적으로 해결했습니다.
핵심 아이디어는 "픽셀 공간에서 diffusion을 하지 말고, 압축된 잠재 공간에서 하자"는 것입니다. VAE로 이미지를 64x64x4 크기의 잠재 표현으로 압축하면, 차원이 16,384로 줄어들어 약 48배 효율적입니다.
이 방법은 품질을 거의 희생하지 않으면서도 학습과 생성 속도를 수십 배 향상시켰습니다. Stable Diffusion, DALL-E 2, Imagen 같은 최신 text-to-image 모델들이 모두 이 원리를 사용합니다.
Latent Diffusion은 AI 이미지 생성을 실용화시킨 결정적 기술입니다.
개요
간단히 말해서, Latent Diffusion Model은 이미지를 VAE로 압축한 잠재 공간에서 diffusion을 수행하여, 효율성과 품질을 동시에 달성한 모델입니다. 왜 잠재 공간에서 작동하는 것이 효과적일까요?
이미지 픽셀의 대부분은 중복된 정보입니다. 예를 들어 파란 하늘은 수만 개의 비슷한 파란색 픽셀로 이루어져 있습니다.
하지만 의미적으로는 "하늘"이라는 하나의 개념일 뿐입니다. VAE는 이런 중복을 제거하고 핵심 의미만 압축합니다.
Diffusion이 "픽셀 수준의 디테일"이 아니라 "의미적 개념"에 집중하도록 하는 것입니다. 이렇게 하면 계산량은 줄어들면서도 이미지의 본질은 유지됩니다.
기존 pixel-space diffusion과 비교: DDPM은 512x512 이미지에서 직접 작동하므로 U-Net이 매우 커야 하고, 학습에 A100 GPU 수백 개가 며칠씩 필요합니다. Latent Diffusion은 64x64 잠재 표현에서 작동하므로 단일 GPU로도 학습 가능하고, 추론은 실시간에 가깝습니다.
Latent Diffusion의 3단계 구조: (1) VAE Encoder가 이미지를 잠재 표현 z로 압축, (2) Diffusion Model이 z에서 작동하여 새로운 z'을 생성, (3) VAE Decoder가 z'을 고해상도 이미지로 복원. VAE는 사전 학습하여 고정하고, Diffusion만 학습합니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
class AutoencoderKL(nn.Module):
"""
Variational Autoencoder for latent space compression
이미지 (3, 512, 512) -> 잠재 표현 (4, 64, 64) 압축 (8x downsampling)
"""
def __init__(self, in_channels=3, latent_channels=4):
super().__init__()
# Encoder: 이미지를 잠재 공간으로 압축
self.encoder = nn.Sequential(
nn.Conv2d(in_channels, 128, 3, stride=2, padding=1), # 512 -> 256
nn.ReLU(),
nn.Conv2d(128, 256, 3, stride=2, padding=1), # 256 -> 128
nn.ReLU(),
nn.Conv2d(256, 512, 3, stride=2, padding=1), # 128 -> 64
nn.ReLU(),
)
# 평균과 로그 분산 계산
self.conv_mu = nn.Conv2d(512, latent_channels, 1)
self.conv_logvar = nn.Conv2d(512, latent_channels, 1)
# Decoder: 잠재 표현을 이미지로 복원
self.decoder = nn.Sequential(
nn.Conv2d(latent_channels, 512, 3, padding=1),
nn.ReLU(),
nn.ConvTranspose2d(512, 256, 4, stride=2, padding=1), # 64 -> 128
nn.ReLU(),
nn.ConvTranspose2d(256, 128, 4, stride=2, padding=1), # 128 -> 256
nn.ReLU(),
nn.ConvTranspose2d(128, in_channels, 4, stride=2, padding=1), # 256 -> 512
nn.Tanh() # -1 ~ 1 범위로 이미지 복원
)
def encode(self, x):
"""이미지 -> 잠재 분포의 평균과 분산"""
h = self.encoder(x)
mu = self.conv_mu(h)
logvar = self.conv_logvar(h)
return mu, logvar
def reparameterize(self, mu, logvar):
"""Reparameterization trick"""
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def decode(self, z):
"""잠재 표현 -> 이미지"""
return self.decoder(z)
def forward(self, x):
mu, logvar = self.encode(x)
z = self.reparameterize(mu, logvar)
return self.decode(z), mu, logvar
class LatentDiffusionModel(nn.Module):
"""
Latent Diffusion: VAE의 잠재 공간에서 Diffusion 수행
"""
def __init__(self, vae, unet, scheduler):
super().__init__()
self.vae = vae
self.unet = unet # Diffusion을 위한 U-Net
self.scheduler = scheduler
# VAE는 학습하지 않음 (사전 학습된 가중치 사용)
for param in self.vae.parameters():
param.requires_grad = False
def forward(self, images, conditioning=None):
"""
학습: 이미지를 잠재 공간으로 인코딩 후 diffusion loss 계산
"""
# 1. 이미지를 잠재 표현으로 인코딩 (gradient 차단)
with torch.no_grad():
mu, logvar = self.vae.encode(images)
latents = self.vae.reparameterize(mu, logvar)
# Scaling factor (Stable Diffusion에서 사용하는 트릭)
latents = latents * 0.18215
# 2. 랜덤 시간 단계 샘플링
batch_size = latents.size(0)
device = latents.device
t = torch.randint(0, self.scheduler.num_timesteps, (batch_size,), device=device)
# 3. Forward diffusion in latent space
noise = torch.randn_like(latents)
noisy_latents = self.scheduler.q_sample(latents, t, noise=noise)
# 4. U-Net으로 노이즈 예측 (conditioning 정보 포함 가능)
if conditioning is not None:
# Text embedding이나 class label을 cross-attention으로 주입
predicted_noise = self.unet(noisy_latents, t, conditioning)
else:
predicted_noise = self.unet(noisy_latents, t)
# 5. MSE Loss
loss = F.mse_loss(predicted_noise, noise)
return loss
@torch.no_grad()
def sample(self, batch_size=1, conditioning=None):
"""
생성: 잠재 공간에서 diffusion 샘플링 후 디코딩
"""
device = next(self.unet.parameters()).device
latent_shape = (batch_size, 4, 64, 64) # (batch, channels, H, W)
# 1. 잠재 공간에서 순수 노이즈로 시작
latents = torch.randn(latent_shape, device=device)
# 2. Reverse diffusion in latent space
for i in reversed(range(self.scheduler.num_timesteps)):
t = torch.full((batch_size,), i, device=device, dtype=torch.long)
# 노이즈 예측
if conditioning is not None:
noise_pred = self.unet(latents, t, conditioning)
else:
noise_pred = self.unet(latents, t)
# Denoising step
latents = self.scheduler.p_sample_from_noise(latents, noise_pred, t, i)
# 3. Scaling 원복
latents = latents / 0.18215
# 4. VAE Decoder로 이미지 생성
images = self.vae.decode(latents)
# 5. [-1, 1] -> [0, 1] 변환
images = (images + 1) / 2
images = images.clamp(0, 1)
return images
# 사용 예시
def train_latent_diffusion(model, dataloader, optimizer, epochs):
"""Latent Diffusion 학습"""
model.train()
for epoch in range(epochs):
for batch_idx, (images, labels) in enumerate(dataloader):
images = images.cuda()
# Diffusion loss 계산 (잠재 공간에서)
loss = model(images, conditioning=None)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if batch_idx % 100 == 0:
print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")
# 주기적으로 샘플 생성하여 확인
if epoch % 5 == 0:
samples = model.sample(batch_size=4)
# save_images(samples, f"samples_epoch_{epoch}.png")
설명
이것이 하는 일: Latent Diffusion은 3단계 파이프라인입니다. 먼저 사전 학습된 VAE Encoder가 512x512 이미지를 64x64x4 잠재 표현으로 압축합니다(64배 압축).
그 다음 Diffusion U-Net이 이 저차원 공간에서 작동하여 새로운 잠재 표현을 생성합니다. 마지막으로 VAE Decoder가 이를 다시 고해상도 이미지로 복원합니다.
첫 번째로, AutoencoderKL이 perceptual compression을 수행합니다. 일반 JPEG 압축과의 차이는 무엇일까요?
JPEG은 고주파 정보를 버려서 압축하지만, 의미적 정보는 고려하지 않습니다. VAE는 "인간이 지각하기에 중요한 정보"를 우선적으로 보존합니다.
예를 들어 얼굴의 눈, 코, 입 위치는 정확히 보존하지만, 배경의 미세한 질감은 약간 손실될 수 있습니다. 하지만 이 손실은 육안으로 거의 구분되지 않습니다.
3단계의 strided convolution으로 8x downsampling을 수행하여 512→256→128→64로 줄입니다. 두 번째로, 잠재 표현의 scaling factor 0.18215가 중요합니다.
왜 이 값일까요? VAE의 잠재 분포가 표준정규분포 N(0,1)에서 약간 벗어날 수 있기 때문에, 경험적으로 찾은 값으로 스케일링하여 diffusion이 작동하기 좋은 범위로 조정합니다.
Stable Diffusion 공식 구현에서도 이 값을 사용합니다. 이것 없이 학습하면 diffusion이 제대로 수렴하지 않을 수 있습니다.
세 번째로, forward pass에서 VAE를 gradient 차단(torch.no_grad)하여 사용합니다. 왜일까요?
VAE는 이미 사전 학습되어 좋은 압축을 제공하므로, diffusion 학습 시 VAE 가중치를 고정합니다. 이렇게 하면 (1) 학습이 안정적이고, (2) 메모리를 절약하며, (3) VAE를 재사용할 수 있습니다.