확산 모델(Diffusion Model) 기초: 노이즈에서 이미지로

DDPM의 순방향·역방향 과정, U-Net 노이즈 예측, InfoNCE 손실 수식, DDIM·DPM-Solver·LCM 스케줄러 비교를 완전 해설합니다.

· 7 min read · PALDYN Team

지난 글에서 이미지와 텍스트를 공동 임베딩 공간에 정렬하는 CLIP을 다뤘다. 이번 글에서는 그 CLIP을 컨디셔닝으로 활용해 텍스트로 이미지를 생성하는 **확산 모델(Diffusion Model)**의 수학적 원리와 구현을 완전 해설한다. Stable Diffusion, DALL-E 3, Imagen 모두 이 원리 위에 세워졌다.

확산 모델의 직관

확산 모델의 아이디어는 간단하다. “이미지를 조금씩 가우시안 노이즈로 오염시키는 과정(순방향)을 학습하면, 그 역과정(역방향)을 신경망으로 배울 수 있다.” 순방향은 손쉽게 수식으로 정의할 수 있고, 역방향은 U-Net이 각 타임스텝 t에서 추가된 노이즈를 예측하도록 학습된다.

확산 모델 순방향·역방향 과정

순방향 과정: 노이즈 추가

타임스텝 t에서 x_{t-1}에 Gaussian 노이즈를 더하는 과정은 아래와 같이 정의된다.

q(x_t | x_{t-1}) = N(x_t; √(1-β_t)·x_{t-1}, β_t·I)

β_t는 t가 커질수록 점점 큰 노이즈를 추가하는 노이즈 스케줄이다. 핵심 트릭은 임의의 타임스텝 t에서 x_t를 한 번에 계산할 수 있다는 점이다.

import torch

def q_sample(
    x0: torch.Tensor,
    t: torch.Tensor,
    sqrt_alphas_cumprod: torch.Tensor,
    sqrt_one_minus_alphas_cumprod: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
    """x0와 타임스텝 t로 x_t를 직접 샘플링"""
    noise = torch.randn_like(x0)

    # ᾱ_t = ∏(1 - β_s) for s=1..t
    sqrt_at = sqrt_alphas_cumprod[t][:, None, None, None]
    sqrt_1mat = sqrt_one_minus_alphas_cumprod[t][:, None, None, None]

    # x_t = √ᾱ_t · x₀ + √(1-ᾱ_t) · ε
    xt = sqrt_at * x0 + sqrt_1mat * noise
    return xt, noise  # x_t와 실제 노이즈 반환


def cosine_schedule(T: int) -> dict:
    """OpenAI 코사인 노이즈 스케줄 (DDPM 선형보다 성능 우수)"""
    steps = T + 1
    t = torch.linspace(0, T, steps) / T
    alphas_cumprod = torch.cos((t + 0.008) / 1.008 * torch.pi / 2) ** 2
    alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
    betas = 1 - alphas_cumprod[1:] / alphas_cumprod[:-1]
    betas = betas.clamp(0, 0.999)

    alphas = 1 - betas
    ac = torch.cumprod(alphas, dim=0)
    return {
        'betas': betas,
        'alphas_cumprod': ac,
        'sqrt_alphas_cumprod': ac.sqrt(),
        'sqrt_one_minus_alphas_cumprod': (1 - ac).sqrt(),
    }

역방향 과정: 노이즈 예측

역방향 과정의 목표는 x_t에서 추가된 노이즈 ε를 예측하는 신경망 ε_θ를 학습하는 것이다. 손실 함수는 단순한 MSE다.

def p_losses(
    model,           # U-Net ε_θ
    x0: torch.Tensor,
    t: torch.Tensor,
    schedule: dict,
) -> torch.Tensor:
    """DDPM 훈련 손실"""
    xt, noise = q_sample(
        x0, t,
        schedule['sqrt_alphas_cumprod'],
        schedule['sqrt_one_minus_alphas_cumprod']
    )
    # 모델이 예측한 노이즈
    pred_noise = model(xt, t)

    # 단순 MSE: 예측 노이즈 vs 실제 노이즈
    return torch.nn.functional.mse_loss(pred_noise, noise)


def train_step(model, optimizer, batch, schedule, device):
    model.train()
    x0 = batch.to(device)
    B = x0.shape[0]
    T = len(schedule['betas'])

    # 랜덤 타임스텝 샘플링
    t = torch.randint(0, T, (B,), device=device)

    optimizer.zero_grad()
    loss = p_losses(model, x0, t, schedule)
    loss.backward()
    optimizer.step()
    return loss.item()

U-Net: 노이즈 예측 아키텍처

확산 모델의 핵심 신경망은 U-Net이다. 인코더(다운샘플링)와 디코더(업샘플링)를 스킵 연결로 연결한 구조로, 다양한 해상도의 특징을 동시에 활용한다. 타임스텝 t는 사인파 위치 인코딩으로 임베딩되어 각 ResNet 블록에 주입된다.

import torch.nn as nn

class TimeEmbedding(nn.Module):
    def __init__(self, dim: int):
        super().__init__()
        self.proj = nn.Sequential(
            nn.Linear(dim, dim * 4),
            nn.SiLU(),
            nn.Linear(dim * 4, dim * 4),
        )

    def forward(self, t: torch.Tensor) -> torch.Tensor:
        # 사인파 위치 인코딩
        half = self.proj[0].in_features // 2
        freqs = torch.exp(
            -torch.log(torch.tensor(10000.0)) *
            torch.arange(half, device=t.device) / half
        )
        emb = t[:, None].float() * freqs[None]
        emb = torch.cat([emb.sin(), emb.cos()], dim=-1)
        return self.proj(emb)

DDPM 샘플링

학습된 모델로 이미지를 생성할 때는 순수 가우시안 노이즈 x_T에서 시작해 역방향 과정을 T번 반복한다.

@torch.no_grad()
def p_sample_loop(
    model,
    shape: tuple,
    schedule: dict,
    device: torch.device,
) -> torch.Tensor:
    """DDPM 샘플링: x_T → x_0"""
    T = len(schedule['betas'])
    x = torch.randn(shape, device=device)

    for t_idx in reversed(range(T)):
        t = torch.full((shape[0],), t_idx, device=device)
        beta_t = schedule['betas'][t_idx]
        alpha_t = 1 - beta_t
        ac_t = schedule['alphas_cumprod'][t_idx]

        # 노이즈 예측
        pred_noise = model(x, t)

        # x_{t-1} 계산
        coef1 = 1 / alpha_t.sqrt()
        coef2 = beta_t / (1 - ac_t).sqrt()
        mean = coef1 * (x - coef2 * pred_noise)

        if t_idx > 0:
            noise = torch.randn_like(x)
            x = mean + beta_t.sqrt() * noise
        else:
            x = mean

    return x.clamp(-1, 1)

노이즈 스케줄러 비교

노이즈 스케줄러 비교

실제 Stable Diffusion 사용 시 diffusers 라이브러리의 스케줄러를 교체하는 것만으로 품질과 속도를 조절할 수 있다.

from diffusers import (
    StableDiffusionPipeline,
    DDIMScheduler,
    DPMSolverMultistepScheduler,
)

pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
).to("cuda")

# 기본 DDIM (50 steps)
pipe.scheduler = DDIMScheduler.from_config(
    pipe.scheduler.config
)

# DPM-Solver++ (20 steps, 비슷한 품질)
pipe.scheduler = DPMSolverMultistepScheduler.from_config(
    pipe.scheduler.config,
    algorithm_type="dpmsolver++",
)

image = pipe(
    "a photo of an astronaut riding a horse on mars",
    num_inference_steps=20,
    guidance_scale=7.5,
).images[0]

Classifier-Free Guidance

텍스트 컨디셔닝 확산 모델에서는 **Classifier-Free Guidance(CFG)**가 품질을 크게 높인다. 조건부 예측과 무조건부 예측을 섞어 텍스트 프롬프트 방향으로 더 강하게 이동시킨다.

ε̃_θ(x_t, c) = ε_θ(x_t, ∅) + w·(ε_θ(x_t, c) - ε_θ(x_t, ∅))

w(guidance_scale)가 클수록 텍스트 정합도가 높아지지만, 다양성이 줄고 과포화(oversaturation) 현상이 생긴다. 일반적으로 7~12 사이를 사용한다.

다음 글에서는 확산 모델을 잠재 공간(Latent Space)으로 가져간 Stable Diffusion의 전체 파이프라인과 실전 사용법을 다룬다.


지난 글: CLIP: 이미지와 텍스트를 같은 공간에 정렬하는 대조 학습

다음 글: Stable Diffusion: 잠재 확산 모델의 구조와 실전 활용


읽어주셔서 감사합니다. 😊