kimyongtae.com
홈 / 강의 / AI 이미지·애니메이션 생성, 원리부터 / 0단계. 최소 기초

0-6. 실습: 작은 디노이저 학습

0단계에서 배운 것을 전부 모아 실제로 돌려 본다. 손글씨 숫자 그림에 노이즈를 섞고, 작은 U-Net이 그 노이즈를 맞히게 가르친다. 코드는 80줄이고 학습은 1분이면 끝난다.

무엇을 만드나

실제 이미지 생성 모델의 학습과 뼈대가 같다. 크기만 다르다.

준비

파이썬과 PyTorch를 쓴다. 이미지 생성 모델과 학습 도구가 전부 PyTorch로 만들어져 있어서, 이 분야를 공부하려면 피할 수 없다.

pip install torch torchvision

그래픽카드가 없어도 돌아간다. 다만 몇 분 걸린다.

전체 코드

전체 코드는 0-6-denoiser.py다. 아래에서 부분별로 설명한다.

1. 데이터

tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize(0.5, 0.5)])
data = datasets.MNIST("data", train=True, download=True, transform=tf)
loader = DataLoader(data, batch_size=128, shuffle=True, drop_last=True)

2. 모델

class Block(nn.Module):
    def __init__(self, cin, cout):
        super().__init__()
        self.conv1 = nn.Conv2d(cin, cout, 3, padding=1)
        self.conv2 = nn.Conv2d(cout, cout, 3, padding=1)
        self.time = nn.Linear(64, cout)

    def forward(self, x, temb):
        h = F.silu(self.conv1(x))
        h = h + self.time(temb)[:, :, None, None]
        return F.silu(self.conv2(h))

블록 하나다. 3×3 합성곱 두 번이고, 그 사이에 시간 정보를 더한다.

class TinyUNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.tmlp = nn.Sequential(nn.Linear(1, 64), nn.SiLU(), nn.Linear(64, 64))
        self.down1 = Block(1, 32)         # 28x28
        self.down2 = Block(32, 64)        # 14x14
        self.mid = Block(64, 128)         # 7x7
        self.up2 = Block(128 + 64, 64)    # 14x14
        self.up1 = Block(64 + 32, 32)     # 28x28
        self.out = nn.Conv2d(32, 1, 1)

    def forward(self, x, t):
        temb = self.tmlp(t[:, None])
        d1 = self.down1(x, temb)
        d2 = self.down2(F.avg_pool2d(d1, 2), temb)
        m = self.mid(F.avg_pool2d(d2, 2), temb)
        u2 = self.up2(torch.cat([F.interpolate(m, scale_factor=2), d2], dim=1), temb)
        u1 = self.up1(torch.cat([F.interpolate(u2, scale_factor=2), d1], dim=1), temb)
        return self.out(u1)

0-4의 U자 그림을 그대로 옮긴 것이다.

3. 학습

for epoch in range(3):
    for x0, _ in loader:
        x0 = x0.to(device)
        t = torch.rand(x0.shape[0], device=device)
        eps = torch.randn_like(x0)
        tt = t[:, None, None, None]
        xt = (1 - tt) * x0 + tt * eps
        loss = F.mse_loss(model(xt, t), eps)
        opt.zero_grad()
        loss.backward()
        opt.step()

핵심은 가운데 다섯 줄이다.

코드뜻
t = torch.rand(…)그림마다 노이즈 비율을 0~1에서 무작위로 고른다
eps = torch.randn_like(x0)픽셀마다 따로 노이즈를 뽑는다. 0-1의 ε
xt = (1 - tt) * x0 + tt * eps0-1의 섞는 식 그대로
model(xt, t)모델이 섞인 그림과 t를 보고 노이즈를 예측한다
F.mse_loss(…, eps)예측과 실제 노이즈의 제곱 오차. 0-2, 0-3의 손실

아래 세 줄은 0-3의 학습 반복이다. loss.backward()가 역전파, opt.step()이 파라미터를 조금 움직이는 일이다.

정답을 따로 만들 필요가 없다는 점을 눈여겨보자. 노이즈를 우리가 직접 섞었으니 정답(ε)을 이미 알고 있다. 그림만 있으면 학습 문제를 끝없이 만들어 낼 수 있다.

4. 확인

xt = (1 - tt) * x0 + tt * eps
with torch.no_grad():
    eps_hat = model(xt, t)
x0_hat = (xt - tt * eps_hat) / (1 - tt)

섞는 식을 x₀에 대해 풀면 x₀ = (x_t − t·ε) / (1 − t)다. 진짜 ε 자리에 모델이 예측한 값을 넣으면 원본을 추정할 수 있다.

돌려 본 결과

파라미터 수: 496065
에폭 1: 평균 손실 0.1949
에폭 2: 평균 손실 0.0850
에폭 3: 평균 손실 0.0724
복원 오차: 0.0464

손실이 내려간다. 아무것도 모르는 모델이 0만 내놓으면 손실이 1 근처다(노이즈의 분산이 1이므로). 0.07이면 노이즈의 대부분을 맞히고 있다는 뜻이다.

결과

여기서 한 것과 안 한 것

한 것: 노이즈가 섞인 그림에서 노이즈를 맞히는 모델을 만들었다. 이것이 디노이저다. 이미지 생성 모델의 심장이다.

안 한 것: 순수 노이즈에서 출발해 새 그림을 만들어 내지는 않았다. 디노이저를 여러 번 불러서 노이즈를 조금씩 걷어 내야 하는데, 그 방법이 1단계의 주제다.

해 볼 것

  1. 확인 코드에서 t를 0.2, 0.8로 바꿔 보자. 얼마나 섞였을 때까지 되찾을 수 있나?
  2. 스킵 연결을 빼 보자. torch.cat을 지우고 up2, up1의 입력 채널을 128, 64로 고치면 된다. 결과가 어떻게 달라지나?
  3. 에폭을 10으로 늘려 보자. 손실이 어디까지 내려가나?

예상 결과

  1. t = 0.2면 거의 완벽하게 되찾는다. t = 0.8이면 숫자의 대략적인 모양만 나오고, 다른 숫자로 바뀌기도 한다. 정보가 그만큼 사라졌기 때문이다.
  2. 손실이 더 높은 곳에서 멈추고 결과가 뭉개진다. 0-4에서 말한 스킵 연결의 역할이다.
  3. 계속 조금씩 내려가지만 0이 되지는 않는다. 노이즈가 많이 섞인 그림에서는 정답을 정확히 알 수 없기 때문이다. 모델은 가능한 답들의 평균을 낸다.
← 0-5 어텐션과 트랜스포머 1-1 오토인코더와 VAE →