이미지 생성 — 확산 모델(Diffusion Model)
확산 모델(Diffusion Model)은 잡음 제거(denoise)를 학습합니다. 잡음이 섞인 이미지(noisy image)에서 아주 작은 잡음(noise)을 제거하도록 학습시키고, 그 과정을 거꾸로 천 번 반복하면 이미지 생성기가 됩니다.
유형: Build
언어: Python
선수 학습: Phase 4 Lesson 07 (U-Net), Phase 1 Lesson 06 (확률), Phase 3 Lesson 06 (옵티마이저)
소요 시간: 약 75분
학습 목표
- 정방향 잡음화 과정(forward noising process)
x_0 -> x_1 -> ... -> x_T를 유도하고, 임의의 시점(timestep) t에 대해 닫힌 형식(closed-form) q(x_t | x_0)가 성립하는 이유를 설명합니다.
- 각 단계에서 추가된 잡음(noise)을 회귀하는 DDPM 스타일 학습 목적 함수(training objective)와, 순수 잡음에서 이미지로 되돌아가는 샘플러(sampler)를 구현합니다.
- 어떤 시점에서도 잡음을 예측할 수 있는 시간 조건화 U-Net(time-conditioned U-Net)을 만듭니다. CPU에서도 학습 가능한 작은 크기로 시작합니다.
- DDPM 샘플링과 DDIM 샘플링의 차이와 각각이 적절한 상황을 설명합니다. 흐름 정합(flow matching)과 직선화 흐름(rectified flow)은 Lesson 23에서 더 깊게 다룹니다.
문제
GAN은 단발성(one-shot)으로 생성합니다. 잡음이 들어가면 한 번의 정방향 패스(forward pass)로 이미지가 나옵니다. 빠르지만 학습이 어렵습니다. 확산 모델은 반복적으로 생성합니다. 순수 잡음에서 시작해 작은 단계로 잡음을 제거하며 이미지가 나타납니다. 느리지만 학습이 쉽습니다. 지난 5년 동안에는 후자의 장점이 압도적이었습니다. 작은 팀도 확산 모델을 학습해 꽤 괜찮은 표본(sample)을 얻을 수 있지만, GAN 학습은 수년의 실패를 통해 익히는 기술에 가깝습니다.
학습 안정성 외에도 확산 모델의 반복 구조는 현대 이미지 생성이 수행하는 거의 모든 기능을 가능하게 합니다. 텍스트 조건화(text conditioning), 부분 채우기(inpainting), 이미지 편집(image editing), 초해상도(super-resolution), 제어 가능한 스타일(controllable style)이 모두 샘플링 루프의 각 단계에 제약을 주입할 수 있다는 점에서 나옵니다. 이 훅(hook) 때문에 Stable Diffusion, Imagen, DALL-E 3, Midjourney와 제어 가능한 이미지 모델 대부분은 확산 기반입니다.
이 레슨에서는 최소 DDPM을 만듭니다. 정방향 잡음화, 역방향 잡음 제거(backward denoising), 학습 루프(training loop)를 구현합니다. 다음 레슨인 Stable Diffusion에서는 여기에 VAE, 텍스트 인코더(text encoder), 분류기 없는 안내(classifier-free guidance)를 연결해 프로덕션 시스템으로 확장합니다.
개념
정방향 과정(Forward Process)
이미지 x_0를 가져옵니다. 작은 가우시안 잡음(Gaussian noise)을 더해 x_1을 만듭니다. 조금 더 더해 x_2를 만듭니다. T 단계까지 계속 진행하면 x_T는 거의 순수 가우시안 잡음과 구분되지 않습니다.
q(x_t | x_{t-1}) = N(x_t; sqrt(1 - beta_t) * x_{t-1}, beta_t * I)
beta_t는 작은 분산 스케줄(variance schedule)입니다. 보통 T=1000 단계에 걸쳐 0.0001에서 0.02까지 선형으로 증가합니다. 각 단계는 신호(signal)를 조금 줄이고 새로운 잡음을 주입합니다.
한 단계씩 잡음을 더하는 것은 마르코프 연쇄(Markov chain)이지만, 수식은 접힙니다. x_0에서 x_t를 한 번에 샘플링할 수 있습니다.
Define alpha_t = 1 - beta_t
Define alpha_bar_t = prod_{s=1..t} alpha_s
Then:
q(x_t | x_0) = N(x_t; sqrt(alpha_bar_t) * x_0, (1 - alpha_bar_t) * I)
Equivalently:
x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * epsilon
where epsilon ~ N(0, I)
이 하나의 식이 확산 모델을 실용적으로 만드는 이유입니다. 학습 중에는 임의의 t를 고르고, x_0에서 x_t를 직접 샘플링한 뒤 한 단계로 학습합니다. 전체 마르코프 연쇄를 모사(simulation)할 필요가 없습니다.
역방향 과정(Reverse Process)
정방향 과정은 고정되어 있습니다. 신경망이 학습하는 것은 역방향 과정(reverse process) p(x_{t-1} | x_t)입니다. 확산 모델은 x_{t-1}을 직접 예측하지 않습니다. 대신 단계 t에서 추가된 잡음 epsilon을 예측하고, 수식으로 x_{t-1}을 유도합니다.
flowchart LR
X0["x_0<br/>(clean image)"] --> Q1["q(x_t|x_0)<br/>add noise"]
Q1 --> XT["x_t<br/>(noisy)"]
XT --> MODEL["model(x_t, t)"]
MODEL --> EPS["predicted epsilon"]
EPS --> LOSS["MSE against<br/>true epsilon"]
XT -.->|sampling| STEP["p(x_{t-1}|x_t)"]
STEP -.-> XT1["x_{t-1}"]
XT1 -.->|repeat 1000x| X0S["x_0 (sampled)"]
style X0 fill:#dcfce7,stroke:#16a34a
style MODEL fill:#fef3c7,stroke:#d97706
style LOSS fill:#fecaca,stroke:#dc2626
style X0S fill:#dbeafe,stroke:#2563eb
학습 손실(Training Loss)
모든 학습 단계는 다음 순서입니다.
- 실제 이미지
x_0를 샘플링합니다.
- 시점
t를 [1, T]에서 균등하게 샘플링합니다.
- 잡음
epsilon ~ N(0, I)를 샘플링합니다.
x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * epsilon을 계산합니다.
- 네트워크로
epsilon_theta(x_t, t)를 예측합니다.
|| epsilon - epsilon_theta(x_t, t) ||^2를 최소화합니다.
이게 전부입니다. 신경망은 임의의 시점에서 잡음을 예측하는 법을 배웁니다. 손실은 평균제곱오차(MSE)입니다. 적대적 게임(adversarial game)도, 붕괴(collapse)도, 진동(oscillation)도 없습니다.
샘플러(Sampler, DDPM)
생성할 때는 x_T ~ N(0, I)에서 시작해 한 단계씩 뒤로 걸어갑니다.
for t = T, T-1, ..., 1:
eps = model(x_t, t)
x_{t-1} = (1 / sqrt(alpha_t)) * (x_t - (beta_t / sqrt(1 - alpha_bar_t)) * eps) + sqrt(beta_t) * z
where z ~ N(0, I) if t > 1, else 0
return x_0
핵심은 역방향 조건부 분포(reverse conditional)가 일반적으로 닫힌 형식으로 알려져 있지 않더라도, 이 가우시안 정방향 과정에서는 가능하다는 점입니다. 복잡해 보이는 계수들은 베이즈 규칙(Bayes' rule)이 주는 값입니다.
왜 1000 단계인가
정방향 잡음 스케줄은 각 단계가 역방향 단계를 거의 가우시안으로 만들 만큼만 잡음을 더하도록 선택됩니다. 단계가 너무 적으면 역방향 단계가 가우시안에서 멀어져 네트워크가 잘 모델링하지 못합니다. 단계가 너무 많으면 샘플링 비용이 커지고 이득은 줄어듭니다. 선형 스케줄(linear schedule)에서 T=1000은 DDPM 기본값입니다.
DDIM: 20배 빠른 샘플링
학습은 같습니다. 샘플링만 바뀝니다. DDIM(Song et al., 2020)은 재학습(retraining) 없이 시점을 건너뛰는 결정론적 역방향 과정(deterministic reverse process)을 정의합니다. DDIM 50단계 샘플링은 DDPM 1000단계에 가까운 품질을 냅니다. 모든 프로덕션 시스템은 DDIM 또는 더 빠른 변형인 DPM-Solver, Euler ancestral 등을 사용합니다.
시간 조건화(Time Conditioning)
네트워크 epsilon_theta(x_t, t)는 자신이 어느 시점을 잡음 제거하는지 알아야 합니다. 현대 확산 모델은 트랜스포머(transformer)의 위치 인코딩(positional encoding)과 같은 아이디어인 사인파 시간 임베딩(sinusoidal time embedding)을 사용하고, 이를 U-Net의 여러 층(level) 특징 지도(feature map)에 더합니다.
t_embedding = sinusoidal(t)
feature_map += MLP(t_embedding)
시간 조건화가 없으면 네트워크가 이미지 자체에서 잡음 수준(noise level)을 추정해야 합니다. 가능은 하지만 표본 효율(sample-efficient)이 훨씬 떨어집니다.
만들어보기
Step 1: 잡음 스케줄(Noise Schedule)
import torch
def linear_beta_schedule(T=1000, beta_start=1e-4, beta_end=2e-2):
return torch.linspace(beta_start, beta_end, T)
def precompute_schedule(betas):
alphas = 1.0 - betas
alphas_cumprod = torch.cumprod(alphas, dim=0)
return {
"betas": betas,
"alphas": alphas,
"alphas_cumprod": alphas_cumprod,
"sqrt_alphas_cumprod": torch.sqrt(alphas_cumprod),
"sqrt_one_minus_alphas_cumprod": torch.sqrt(1.0 - alphas_cumprod),
"sqrt_recip_alphas": torch.sqrt(1.0 / alphas),
}
schedule = precompute_schedule(linear_beta_schedule(T=1000))
한 번 사전 계산(precompute)한 뒤 학습과 샘플링 중 인덱스로 모아 사용합니다.
Step 2: 정방향 확산(Forward Diffusion, q_sample)
def q_sample(x0, t, noise, schedule):
sqrt_a = schedule["sqrt_alphas_cumprod"][t].view(-1, 1, 1, 1)
sqrt_one_minus_a = schedule["sqrt_one_minus_alphas_cumprod"][t].view(-1, 1, 1, 1)
return sqrt_a * x0 + sqrt_one_minus_a * noise
닫힌 형식 한 줄입니다. t는 배치(batch) 안의 이미지마다 하나씩 가진 시점 배치입니다.
Step 3: 작은 time-conditioned U-Net
import torch.nn as nn
import torch.nn.functional as F
import math
def timestep_embedding(t, dim=64):
half = dim // 2
freqs = torch.exp(-math.log(10000) * torch.arange(half, device=t.device) / half)
args = t[:, None].float() * freqs[None]
emb = torch.cat([args.sin(), args.cos()], dim=-1)
return emb
class TinyUNet(nn.Module):
def __init__(self, img_channels=3, base=32, t_dim=64):
super().__init__()
self.t_mlp = nn.Sequential(
nn.Linear(t_dim, base * 4),
nn.SiLU(),
nn.Linear(base * 4, base * 4),
)
self.t_dim = t_dim
self.enc1 = nn.Conv2d(img_channels, base, 3, padding=1)
self.enc2 = nn.Conv2d(base, base * 2, 4, stride=2, padding=1)
self.mid = nn.Conv2d(base * 2, base * 2, 3, padding=1)
self.dec1 = nn.ConvTranspose2d(base * 2, base, 4, stride=2, padding=1)
self.dec2 = nn.Conv2d(base * 2, img_channels, 3, padding=1)
self.time_proj = nn.Linear(base * 4, base * 2)
def forward(self, x, t):
t_emb = timestep_embedding(t, self.t_dim)
t_emb = self.t_mlp(t_emb)
t_proj = self.time_proj(t_emb)[:, :, None, None]
h1 = F.silu(self.enc1(x))
h2 = F.silu(self.enc2(h1)) + t_proj
h3 = F.silu(self.mid(h2))
d1 = F.silu(self.dec1(h3))
d2 = torch.cat([d1, h1], dim=1)
return self.dec2(d2)
병목(bottleneck) 지점에 시간 조건화를 주입하는 두 단계 U-Net입니다. 실제 이미지에서는 깊이(depth)와 너비(width)를 키웁니다.
Step 4: 학습 루프(Training Loop)
def train_step(model, x0, schedule, optimizer, device, T=1000):
model.train()
x0 = x0.to(device)
bs = x0.size(0)
t = torch.randint(0, T, (bs,), device=device)
noise = torch.randn_like(x0)
x_t = q_sample(x0, t, noise, schedule)
pred = model(x_t, t)
loss = F.mse_loss(pred, noise)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss.item()
전체 학습 루프의 핵심은 이것입니다. GAN의 적대 게임도, 특수한 손실 함수도 없고, MSE 한 번뿐입니다.
Step 5: 샘플러(Sampler, DDPM)
@torch.no_grad()
def sample(model, schedule, shape, T=1000, device="cpu"):
model.eval()
x = torch.randn(shape, device=device)
betas = schedule["betas"].to(device)
sqrt_one_minus_a = schedule["sqrt_one_minus_alphas_cumprod"].to(device)
sqrt_recip_alphas = schedule["sqrt_recip_alphas"].to(device)
for t in reversed(range(T)):
t_batch = torch.full((shape[0],), t, dtype=torch.long, device=device)
eps = model(x, t_batch)
coef = betas[t] / sqrt_one_minus_a[t]
mean = sqrt_recip_alphas[t] * (x - coef * eps)
if t > 0:
x = mean + torch.sqrt(betas[t]) * torch.randn_like(x)
else:
x = mean
return x
표본 배치 하나를 만들려면 1000번의 정방향 패스가 필요합니다. 실제 코드에서는 보통 DDIM 50단계 샘플러로 교체합니다.
Step 6: DDIM 샘플러(결정론적, 약 20배 빠름)
@torch.no_grad()
def sample_ddim(model, schedule, shape, steps=50, T=1000, device="cpu", eta=0.0):
model.eval()
x = torch.randn(shape, device=device)
alphas_cumprod = schedule["alphas_cumprod"].to(device)
ts = torch.linspace(T - 1, 0, steps + 1).long()
for i in range(steps):
t = ts[i]
t_prev = ts[i + 1]
t_batch = torch.full((shape[0],), t, dtype=torch.long, device=device)
eps = model(x, t_batch)
a_t = alphas_cumprod[t]
a_prev = alphas_cumprod[t_prev] if t_prev >= 0 else torch.tensor(1.0, device=device)
x0_pred = (x - torch.sqrt(1 - a_t) * eps) / torch.sqrt(a_t)
sigma = eta * torch.sqrt((1 - a_prev) / (1 - a_t) * (1 - a_t / a_prev))
dir_xt = torch.sqrt(1 - a_prev - sigma ** 2) * eps
noise = sigma * torch.randn_like(x) if eta > 0 else 0
x = torch.sqrt(a_prev) * x0_pred + dir_xt + noise
return x
eta=0이면 완전히 결정론적입니다. 같은 잡음 입력은 항상 같은 출력을 만듭니다. eta=1은 DDPM에 가까워집니다.
활용하기
프로덕션 작업에서는 diffusers를 사용합니다.
from diffusers import DDPMScheduler, UNet2DModel
unet = UNet2DModel(sample_size=32, in_channels=3, out_channels=3, layers_per_block=2)
scheduler = DDPMScheduler(num_train_timesteps=1000)
이 라이브러리는 준비된 스케줄러(DDPM, DDIM, DPM-Solver, Euler, Heun), 설정 가능한 U-Net, 텍스트에서 이미지로(text-to-image), 이미지에서 이미지로(image-to-image) 파이프라인, LoRA 파인튜닝 헬퍼를 제공합니다.
연구용으로는 k-diffusion(Katherine Crowson)이 가장 충실한 참조 구현(reference implementation)과 좋은 샘플링 변형을 제공합니다.
산출물 만들기
이 레슨의 최종 산출물은 다음과 같습니다.
outputs/prompt-diffusion-sampler-picker.md — 품질 목표(quality target), 지연 예산(latency budget), 조건화 유형(conditioning type)을 바탕으로 DDPM / DDIM / DPM-Solver / Euler 중 샘플러를 고르는 프롬프트입니다.
outputs/skill-noise-schedule-designer.md — T와 목표 손상 수준(corruption level)을 받아 선형(linear), 코사인(cosine), 시그모이드(sigmoid) 베타 스케줄을 만들고 시간에 따른 신호 대 잡음비(signal-to-noise ratio) 진단을 제공하는 스킬입니다.
연습문제
- (쉬움) 정방향 과정을 시각화합니다. 이미지 하나를 가져와
t in [0, 100, 250, 500, 750, 1000]에서의 x_t를 그립니다. x_1000이 순수 가우시안 잡음처럼 보이는지 확인합니다.
- (중간) TinyUNet을 합성 원형(synthetic-circles) 데이터셋(dataset)에서 20 에포크(epoch) 학습하고 원 16개를 샘플링합니다. DDPM(1000단계)과 DDIM(50단계)을 비교합니다. 같은 잡음 시드(noise seed)에서 비슷한 이미지를 만들까요?
- (어려움) 코사인 잡음 스케줄(Nichol & Dhariwal, 2021)을 구현합니다.
alpha_bar_t = cos^2((t/T + s) / (1 + s) * pi / 2)입니다. 같은 모델을 선형 스케줄과 코사인 스케줄로 학습하고, 낮은 단계 수에서 코사인이 더 나은 표본을 주는지 보입니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 정방향 과정(Forward process) | "시간에 따라 잡음 추가" | 이미지를 T 단계에 걸쳐 가우시안 잡음으로 손상시키는 고정된 마르코프 연쇄입니다. |
| 역방향 과정(Reverse process) | "한 단계씩 잡음 제거" | 잡음에서 이미지로 되돌아가는 학습된 분포입니다. |
| 엡실론 예측(Epsilon prediction) | "잡음 예측" | 학습 목표입니다. epsilon_theta(x_t, t)는 단계 t에서 추가된 잡음을 예측합니다. |
| 베타 스케줄(Beta schedule) | "잡음 양" | 단계마다 얼마나 잡음이 들어가는지 정의하는 T개의 작은 분산 수열입니다. |
| alpha_bar_t | "누적 신호 보존 계수" | 시점 t까지의 (1 - beta_s) 곱입니다. t가 클수록 남은 신호가 적습니다. |
| DDPM 샘플러 | "조상적, 확률적(Ancestral, stochastic)" | 각 x_{t-1}을 조건부 가우시안에서 샘플링합니다. 보통 1000단계입니다. |
| DDIM 샘플러 | "결정론적, 빠름(Deterministic, fast)" | 샘플링을 결정론적 상미분 방정식(ODE)으로 다시 씁니다. 20~100단계에서도 비슷한 품질을 냅니다. |
| 시간 조건화(Time conditioning) | "모델에게 t 알려주기" | U-Net에 t의 사인파 임베딩을 주입해 잡음 수준을 알게 합니다. |
더 읽을거리