1. Chpater 7 - 손으로 쓴 숫자 훈련
GAN으로 MNIST Dataset을 Real Training Dataset으로 사용하여 Generator가 동일한 크기의 이미지를 만들도록 한다.
훈련이 진행될수록 Generator의 Image가 점차 Real Image와 같아지면서 Discriminator를 속일 수 있을 정도까지 발전하게 해야 한다.
이제 앞에서 사용했던 Library들을 import 해보자.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset
import numpy as np
import pandas as pd
import random
import matplotlib.pyplot as plt
1.1) Dataset Class
pytorch의 torch.utils.data.Dataset Class를 사용한다.
이를 이용하여 MNISTDataset Class를 생성하자.
해당 Class는 Data를 tensor로 묶고, 각 index마다 target class,
0부터 1사이의 값으로 재조정된 image pixel 값,
one-hot encoding이 된 tensor를 반환한다.
PyTorch Dataset 생성시 주로 torch.utils.data.Dataset Class에서 상속을 받는데, 이때 아래의 3가지가 필수적으로 구현돼야 한다.
- __init__(self): Constructor, 생성자
- __len__(self): Dataset의 길이(length)를 반환
- __getitem__(self, idx): Dataset의 idx번째 item에 대해 image, label 반환
class MNISTDataset(Dataset):
def __init__(self, file_path):
super(MNISTDataset, self).__init__()
self.data_df = pd.read_csv(file_path, header=None)
pass
def __len__(self):
return (self.data_df).size[0]
def __getitem__(self, index):
# image label
label = self.data_df.iloc[index, 0]
# one-hot encoding vector
target = torch.zeros(10)
target[label] = 1.0
# 0-255의 image를 0-1로 Normalize
image_values = torch.FloatTensor([self.data_df.iloc[index, 1:].values]) / 255.0
# label, image tensor, one-hot encoded tensor 반환
return label, image_values, target
def plot_image(self, index):
# 28 * 28의 pixel 형태로 reshape
img = self.data_df.iloc[index, 1:].values.reshape(28, 28)
plt.title("label = " + str(self.data_df.iloc[index, 0]))
plt.imshow(img, interpolation="none", cmap="Blues")
pass
pass
Training set의 임의의 이미지를 불러와 그려보면서 Dataset Class가 제대로 작동되는지 확인해보자.
# load data
mnist_dataset = MNISTDataset(
"/Users/eric/Documents/AI/BITAmin/Project/2024_spring/mnist_train.csv"
)
mnist_dataset.plot_image(17)
M1 Mac Air에서 PyTorch 연산을 가속시키기 위해 M1 Silicon에서 사용되는 GPU와 유사한 가속인 MPS Device로 옮겨 실행한다.
device = torch.device("mps:0" if torch.backends.mps.is_available() else "cpu")
1.2) MNIST Discriminator
이전에 사용했던 1010 패턴 GAN 코드의 Discriminator와 거의 비슷하고, 신경망 크기만 다르다.
Discriminator는 궁극적으로 Real인지 Fake인지를 구별해야 하는
Binary-Classification Task를 수행해야 하기 때문에,
항상 Output Layer(FC Layer)의 out_features가 1이다.
class Discriminator(nn.Module):
def __init__(self):
super(Discriminator, self).__init__()
self.model = nn.Sequential(
nn.Linear(784, 200), nn.Sigmoid(), nn.Linear(200, 1), nn.Sigmoid()
)
self.loss_function = nn.MSELoss()
self.optimizer = optim.SGD(self.parameters(), lr=1e-2)
self.counter = 0
self.progress = []
pass
def forward(self, x):
return self.model(x)
def train(self, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
outputs = self.forward(inputs)
loss = self.loss_function(outputs, targets)
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item)
pass
if self.counter % 10000 == 0:
print(f"counter = {self.counter}")
pass
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
def plot_progress(self):
df = pd.DataFrame(self.progress, column=["loss"])
df.plot(
ylim=(0, 1.0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5),
)
pass
1.3) Discriminator 테스트하기
Generator를 만들기 전에,
Discriminator가 Real Image와 Arbitrary한 Noise가 섞인 Image간에 구별을 할 수 있는지 한 번 살펴보고 가야 한다.
다음은 60,000개의 이미지를 훑으며 Discriminator가 진짜 숫자라고 판단하면 결과를 1.0으로 출력하도록 하는 코드이다.
def generate_random(size):
return torch.rand(size)
%%time
D = Discriminator().to(device)
for label, image_data_tensor, target_tensor in mnist_dataset:
# real data
D.train(image_data_tensor, torch.FloatTensor([1.0]))
# arbitrary data
D.train(generate_random(784), torch.FloatTensor([0.0]))
각각의 실제 이미지마다 generate_random(784)를 이용해 임의의 가짜 노이즈 이미지 pixel 값을 만든다.
Discriminator는 이 Noise값에 대해 0.0을 반환하도록 훈련한다.
작업 시간을 측정하기 위해 셀의 맨 위쪽에 %%time 을 추가하는 것을 잊지 말자.
필자의 M1 Mac Air 기준으로는 약 5m 14s 정도가 걸렸다.
counter = 10000
counter = 20000
counter = 30000
counter = 40000
counter = 50000
counter = 60000
counter = 70000
counter = 80000
counter = 90000
counter = 100000
counter = 110000
counter = 120000
CPU times: user 4min 10s, sys: 31.2 s, total: 4min 41s
Wall time: 5min 14s
이제 훈련이 진행되면서 Loss가 어떻게 변하는지 차트를 통해 확인해보자.
D.plot_progress()
Loss가 거의 0 근처로 조정되는 것을 볼 수 있다.
이제 훈련된 Discriminator에 임의의로 선택한 이미지들을 수동으로 넣어 결과를 한 번 확인해보자.
일부 이미지는 의도적으로 임의의 Noise 이미지를 사용했다.
# manually run discriminator to check it can tell real data from fake
for _ in range(4):
image_data_tensor = mnist_dataset[np.random.randint(0, 60000)][1]
print(D.forward(image_data_tensor.to(device)).item())
pass
for _ in range(4):
print(D.forward(generate_random(784).to(device)).item())
forward(x) 함수 사용시 input data인 x를 MPS device로 옮겨주지 않아 오류가 날 수 있다.
즉, forward(x.to(device))와 같이 forward 사용시 안의 input data인 x를 x.to(device)로 MPS device로 옮겨줘야 한다.
0.9958692193031311
0.9945105314254761
0.9983219504356384
0.9968407154083252
0.005566069856286049
0.005591141991317272
0.004557173699140549
0.0051549701020121574
결과를 분석하면, 실제 이미지들은 1에 가깝게 나오고, 임의의 노이즈가 섞인 이미지들은 0에 가깝게 나온다.
이는 Discriminator가 Real Image와 Arbitrary Noise를 잘 구별한다는 뜻이다.
이제 Discriminator가 최소한 실제 이미지와 임의의 노이즈 이미지를 구별할 수 있다는 점을 알았다.
1.4) MNIST Generator
Generator는 생성된 Image가 MNISTDataset과 같은 형식이어야 한다.
즉 Generator가 생성한 Image는 $28 \times 28$ 크기이며 총 784개의 pixel 값을 가져야 한다.
일단 Discriminator 구조를 Reverse하여 Generator 구조를 생성하자.
- Input Layer Node: 1
- Hidden Layer Node: 200
- Output Layer Node: 784
Q. Generator의 Input을 왜 매 training epoch마다 다르게 해야 하나?
A. Neural Network는 동일한 input에 대해 언제나 동일한 output(Label)을 출력한다.
물론 훈련 과정 자체에 어느정도 임의적인 요소는 있을 수 있으나,
같은 Input에 대해서는 항상 같은 Output을 낸다는 점은 변하지 않는다.
즉, Generator가 항상 정확히 같은 Class, Label에 해당하는 이미지를 출력하게 되는 것이다.
하지만, Generator는 Training Data의 여러 양상을 다양하게 반영하도록 image를 훈련해야 한다.
전에 훈련했던 1010 GAN은 Generator가 생성해야 했던 '1010 패턴'이 늘 동일한 Class, Label이기 때문에
늘 똑같은 데이터를 만들었다. 그렇기에 Generator의 입력으로 상수 0.5만 들어간 것이다.
하지만 이제 Generator는 다양한 Class, Label에 해당하는 이미지를 만들어야 하기에 Input이 다양해야 한다.
따라서 매 훈련 사이클마다 임의적인 입력을 사용하면 된다. 이제 임의의 시드(random seed)에서 숫자를 생성하도록 하자.
Random Seed를 이용해서 Generator를 작동하는 것이 왜 서로 다른 이미지를 생성하는데 도움이 될까?
이는 정확히 Generator가 어떠한 원리를 통해 작동하는지는 모르지만, Generator로 입력되는 조금씩 다른 다른 숫자가 다양한 이미지를 생성하는데 도움을 준다고 볼 수 있기 때문이다.
예를 들어, 0.0과 0.2 사이의 입력은 3이라는 이미지를 만드는데 영향을 끼치며
0.4와 0.6 사이의 입력은 9라는 이미지를 만드는데 일조한다.
class Generator(nn.Module):
def __init__(self):
# PyTorch Parent Class 초기화
super(Generator, self).__init__()
self.model = nn.Sequential(
nn.Linear(1, 200), nn.Sigmoid(), nn.Linear(200, 784), nn.Sigmoid()
)
# SGD optimizer 설정
self.optimizer = optim.SGD(self.parameters(), lr=1e-2)
# 진행 측정을 위한 변수 초기화
self.counter = 0
self.progress = []
pass
def forward(self, x):
# model 실행
return self.model(x)
def train(self, D, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
# 신경망 출력 계산
g_output = self.forward(inputs)
# 판별기로 전달
d_output = D.forward(g_output)
# 오차 계산
loss = D.loss_function(d_output, targets)
# counter를 증가시키고 10회마다 오차 저장
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
# 기울기를 초기화하고 역전파 후 가중치 갱신
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
pass
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=(0, 1.0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5),
)
pass
1.5) Generator 결과 확인하기
GAN을 훈련하기 전에 Generator가 생성한 데이터의 형태가 올바르게 나왔는지 확인하자.
G = Generator()
output = G.forward(generate_random(1))
img = output.detach().numpy().reshape(28, 28)
plt.imshow(img, interpolation="none", cmap="Blues")
새로운 Generator 객체를 만들고, 임의의 시드를 통해 output tensor를 하나 만들어보자. (784개의 값이 있는 tensor가 반환된다)
사진을 보면 임의의 Noise같은 이미지가 보이면서, Generator가 훈련 전이라는 사실을 알 수 있다.
이는 정상적이며, 오히려 이미지에 패턴이 있다면 앞선 과정에서 실수가 있었다는 뜻이다.
1.6) GAN 훈련하기
제 GAN을 훈련해보자. 훈련 반복문은 Chapter 6과 동일하다.
%%time
# Discriminator & Generator 생성
D = Discriminator().to(device)
G = Generator().to(device)
# Discriminator & Generator 훈련
for label, image_data_tensor, target_tensor in mnist_dataset:
# 1단계: Discriminator Real-Image에 대해 훈련
D.train(image_data_tensor, torch.FloatTensor([1.0]))
# 2단계: Discriminator Fake-Image에 대해 훈련
# Generator Weight Update X: detach() 함수를 사용
D.train(G.forward(generate_random(1).to(device)).detach(), torch.FloatTensor([0.0]))
# 3단계: Generator가 만든 Fake-Image에 대해 Discriminator가 Real_Image로 착각하게끔 훈련
G.train(D, generate_random(1), torch.FloatTensor([1.0]))
pass
필자의 M1 Mac Air 기준으로 MPS device에서 돌릴 경우 9min 29s가 걸렸다.
counter = 10000
counter = 20000
counter = 30000
counter = 40000
counter = 50000
counter = 60000
counter = 70000
counter = 80000
counter = 90000
counter = 100000
counter = 110000
counter = 120000
CPU times: user 7min 52s, sys: 49.5 s, total: 8min 42s
Wall time: 9min 29s
이제 Discriminator와 Generator를 훈련시키면서 나온 Loss들을 시각화해보자.
D.plot_progress()
Discriminator의 경우 Loss가 0으로 가까워졌고, 한동안 이를 유지하는데, Discriminator가 Generator를 성능으로 앞서는 부분이다.
이후 Loss가 0.25 바로 아래까지 치솟게 되는데, 이는 Discriminator와 Generator가 균형이 맞기 시작했다는 의미로 볼 수 있다.
이후에는 Discriminator가 다시 앞서서 Loss가 낮은 상태에 머물러 있음을 알 수 있다.
우리가 원하는 상황은 Loss가 0.25 정도를 보이면서 Discriminator와 Generator 간의 성능에 균형이 맞는 것이다.
이 지점에서 Discriminator는 Generator가 만든 이미지와 실제 이미지가 비슷해서 잘 구별하지 못하게 된다.
만약 Loss가 0에 가깝게 나온다면, 이는 Generator의 성능이 떨어져 Discriminator를 속일 수 없는 상태일 것이다.
G.plot_progress()
초기에 Loss가 치솟는 이유는, Discriminator가 Generator로부터 나온 이미지들을 잘 구별하기 때문이다.
이후 Loss가 다시 0.25 근처로 하락하여 Discriminator와 Generator 간 균형이 잘 맞는 상태가 된다.
중반 이후부터는 다시 Loss가 상승하는데, 이는 Discriminator의 성능이 Generator의 성능보다 더 나은 구간이라고 볼 수 있다.
이제 Generator가 만들어낸 이미지들을 한 번 살펴보자. 단순히 어떻게 생겼나 확인하겠다는 것이 아니라, 배울만한 것이 있기 때문이다.
서로 다른 임의의 시드에서 각기 다른 이미지가 생성될 것이라 예측하므로, 여러 장의 이미지를 골라 그려보자.
# 2행 3열로 생성된 이미지 출력
f, axarr = plt.subplots(2, 3, figsize=(16, 8))
for i in range(2):
for j in range(3):
output = G.forward(generate_random(1).to(device))
img = output.detach().cpu().numpy().reshape(28, 28)
axarr[i, j].imshow(img, interpolation="none", cmap="Blues")
pass
pass
Generator에서 나온 이미지들을 보고 깨달을 수 있는 점은 단지 임의의 노이즈가 아니라 어떠한 형태를 가지고 있다는 점이다. 마치 손으로 쓴 숫자처럼 어두운 부분(점이 많은 부분)이 이미지의 가운데즈음에 위치해있다.
이는 확실히 긍정적인 현상이다. 무언가 숫자로 인식할 수 있는 이미지의 초기 단계정도 되는 것이다.
필자가 볼 때는 숫자 '5'정도로 보이는 것 같다.
이미지가 정확한 숫자 이미지는 아니지만, 꽤 단순한 코드로 이 정도까지 신경망이 스스로 그린 것은 대단하다고 볼 수 있다.
즉, Generator가 MNIST Dataset에서 직접 이미지를 본 적이 없는데도 이만큼 훈련을 한 것이다.
이제 생성된 이미지들을 다시 보자. Generator를 통해 생성된 이미지들이 모두 동일하게 생긴 것을 볼 수 있다.
이러한 현상은 GAN을 훈련할 때 자주 발생하는 Mode Collapse(모드 붕괴)라는 현상이다.
2. Mode Collapse
Seeing a lot of confusion regarding these two terms in GAN papers lately.
Mode Collapse: When a large region of a model's input space maps to a small region around a single (often bad) sample.
Mode Dropping: When modes in the data are not represented in the output of the model.
MNIST 예제에서 알 수 있듯이, Generator가 10개의 숫자를 다양하게 생성하는 것이 중요하다.
그러나 Mode Collapse가 일어나면, Generator는 오직 하나의 single sample로만 매핑이 되거나 선택지의 극히 일부만 만들게 된다.
현재로서는 Mode Collpase가 왜 일어나는지에 대해 명확하게 이해된 바가 없다. 즉 연구는 계속해서 진행되고 있고, 아이디어는 계속해서 발전 중이다.
Mode Collpase가 왜 발생하는지에 대한 그럴듯한 이론은, Generator가 Discriminator보다 더 앞서간 후에 항상 실제에 결과가 가깝게 나오는 '꿀 지점'을 발견하여 그 이미지를 계속해서 만들어내게 된다는 점이다.
이 현상을 완화하는 방법은 Discriminator를 Generator보다 더 자주 훈련시키는 것이다.
하지만 이 방법은 실제로 효과를 볼 수 있는 방법이 아니다.
훈련의 양보다는 질이 더 중요하기 때문이다.
예를 들면, Discriminator의 Loss가 높아지는 구간에서는 학습이 안 된다는 것을 알 수 있다. 이는 Discriminator가 제대로 일을 하지 못하는 지경에 이르러 좋은 피드백을 주지 못한다고 볼 수 있다.
즉, 훈련의 질이 중요한 시점이라는 것을 알 수 있다.
2.1) GAN 훈련 성능 향상하기 (1)
Mode Collapse를 고치고 이미지 선명도를 높이기 위해 GAN의 훈련 품질을 더 높여보자.
첫 번째 방법은 MSELoss( ) 대신 BCELoss( )를 이용하여 Loss Function을 대체하는 방법이다.
Binary Cross-Entropy Loss(BCELoss)는 확실하게 틀리는 경우에 특히 더 큰 penalty를 주는 방식이다.
즉, BCELoss가 MSELoss보다 Reward & Penalty의 강도가 더 세기 때문에 Loss Function으로서 더 적합하다.
두 번째 방법은 Sigmoid( ) 대신 LeakyReLU( )를 이용하여 Activation Function을 대체하는 방법이다.
Sigmoid 함수에서 발생하는 Gradient Vanishing Problem을 해결하기 위해 LeakyReLU를 사용한다.
다만, Task가 Binary-Classification이므로
Output Layer의 Activation Function은 Sigmoid( )로 고정하고, Hidden Layer의 Activation만 LeakyReLU( )로 변경한다.
세 번째 방법은 신경망에서 나오는 신호에 대해 Normalization을 진행하여 평균을 0으로 맞추고, 분산을 제한하여 극단적인 값을 피하는 방법으로, LyaerNorm( )을 사용한다.
마지막으로 Optimizer를 SGD에서 Adam으로 대체하는 방법이다.
따라서, 위의 4가지 수정사항을 바탕으로 아래와 같이 코드를 작성할 수 있다.
class Discriminator(nn.Module):
def __init__(self):
super(Discriminator, self).__init__()
self.model = nn.Sequential(
nn.Linear(784, 200),
nn.LeakyReLU(0.02),
nn.LayerNorm(200),
nn.Linear(200, 1),
nn.Sigmoid(),
)
self.loss_function = nn.BCELoss()
self.optimizer = optim.Adam(self.parameters(), lr=1e-4)
self.counter = 0
self.progress = []
pass
def forward(self, x):
return self.model(x)
def train(self, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
# nn.BCELoss()에서는 outputs, targets 모두 torch.Size([1])를 요구하므로 차원 조정
# outputs는 원래 torch.Size([N, 1]): N은 Batch size
outputs = self.forward(inputs).view(-1)
loss = self.loss_function(outputs, targets)
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
if self.counter % 10000 == 0:
print(f"counter = {self.counter}")
pass
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=(0, 1.0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5),
)
pass
class Generator(nn.Module):
def __init__(self):
# PyTorch Parent Class 초기화
super(Generator, self).__init__()
self.model = nn.Sequential(
nn.Linear(1, 200),
nn.LeakyReLU(0.02),
nn.LayerNorm(200),
nn.Linear(200, 784),
nn.Sigmoid(),
)
# SGD optimizer 설정
self.optimizer = optim.Adam(self.parameters(), lr=1e-4)
# 진행 측정을 위한 변수 초기화
self.counter = 0
self.progress = []
pass
def forward(self, x):
# model 실행
return self.model(x)
def train(self, D, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
# 신경망 출력 계산
g_output = self.forward(inputs)
# 판별기로 전달
d_output = D.forward(g_output).view(-1)
# 오차 계산
loss = D.loss_function(d_output, targets)
# counter를 증가시키고 10회마다 오차 저장
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
# 기울기를 초기화하고 역전파 후 가중치 갱신
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
pass
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=(0, 1.0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5),
)
pass
Q. 왜 Discriminator와 Generator의 Model output을 차원을 1차원 Tensor로 Flatten 했는가?
A. Discriminator / Generator는 train함수 안에 다음과 같이 output을 1차원 tensor로 Flatten 시켰다.
# Discriminator
outputs = self.forward(inputs).view(-1)
loss = self.loss_function(outputs, targets)
# Generator
d_output = D.forward(g_output).view(-1)
loss = D.loss_function(d_output, targets)
그 이유는 loss_function인 nn.BCELoss( )안에 들어가는 outputs와 targets의 shape이 동일해야 하기 때문이다.
그런데, outputs의 shape은 tensor의 크기가 batch_size = N, Class 개수 = k 에 대하여 [N, k] = [1, 1] 인 반면
targets의 shape은 tensor의 크기가 [1]이므로 outputs를 1차원 tensor로 flatten (.view(-1)) 시켜야 둘의 shape이 동일해진다.
counter = 10000
counter = 20000
counter = 30000
counter = 40000
counter = 50000
counter = 60000
counter = 70000
counter = 80000
counter = 90000
counter = 100000
counter = 110000
counter = 120000
CPU times: user 21min 49s, sys: 1min 11s, total: 23min 1s
Wall time: 24min 32s
결과를 확인해보면 다음과 같다.
아쉽게도, 아직은 모든 이미지가 같은 형상을 띄는 것으로 보아 Mode Collapse 현상은 지속중이다.
다만 이미지가 조금 더 깨끗해지고, 조금 더 식별가능해지긴 했으나 아직 분명하게 숫자라고 볼 수 있을 정도는 아니다.
GAN 구조를 어떻게 향상할 수 있을지 더 깊게 생각해보자.
2.2) GAN 훈련 성능 향상하기 (2)
생성의 첫 단계는 Seed이다.
지금까지는 0.5라는 고정된 값을 주면 신경망이 항상 같은 값을 결과로 내놓았기 때문에 한 개의 임의의 값을 통해 이를 해결했다.
다만, 하나의 값만으로 Generator가 10개의 숫자에 대한 784개의 pixel을 온전히 만드는 것은 역부족이다.
따라서 시도해볼만한 쉬운 방법은 입력 Seed에 충분히 많은 숫자를 넣는 것이다.
일단 임의의 값으로 Input Node를 100개 만들어 시작해보자. Generator의 Network 구현부터 수정한다.
class Generator(nn.Module):
def __init__(self):
# PyTorch Parent Class 초기화
super(Generator, self).__init__()
self.model = nn.Sequential(
nn.Linear(100, 200),
nn.LeakyReLU(0.02),
nn.LayerNorm(200),
nn.Linear(200, 784),
nn.Sigmoid(),
)
# SGD optimizer 설정
self.optimizer = optim.Adam(self.parameters(), lr=1e-4)
# 진행 측정을 위한 변수 초기화
self.counter = 0
self.progress = []
pass
def forward(self, x):
# model 실행
return self.model(x)
def train(self, D, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
# 신경망 출력 계산
g_output = self.forward(inputs)
# 판별기로 전달
d_output = D.forward(g_output).view(-1)
# 오차 계산
loss = D.loss_function(d_output, targets)
# counter를 증가시키고 10회마다 오차 저장
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
# 기울기를 초기화하고 역전파 후 가중치 갱신
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
pass
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=(0, 1.0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5),
)
pass
이미지가 조금 더 깨끗해지고, '2'라는 숫자가 더 손으로 쓴 글씨 같아졌다.
다만 아직도 입력 Seed에 충분히 많은 숫자(100개)를 넣었음에도 Mode Collapse는 해결이 되지 않는다.
이를 해결하려면 Discriminator와 Generator의 Input에 Random Seed를 주기 위한 임의의 방식이 서로 달라야 할 것이다.
Discriminator에 입력되는 임의의 Image pixel 값은 0에서 1사이에서 고르게 선택해야 한다.
범위가 0부터 1인 이유는 이것이 실제 데이터셋에서 들어오는 image_data_tensor 에서 관찰되는 값이기 때문이다.
자연적으로 형성된 임의의 숫자에 대해 판별기의 성능을 테스트할 것이기 때문에 값 또한 고르게 선택해야 하며, 정규분포처럼 어떠한 경향성을 지니면 안된다.
Generator에 입력되는 임의의 Image pixel값은 0부터 1사이의 값이 아니여도 된다.
다만, 신경망에서 평균이 0이고 분산이 제한된 Normalize된 값들이 학습에 유리하긴 하다.
즉, Generator에서는 평균이 0이고 분산이 1인 Gaussian(Normal) Distribution에서 값을 추출하는 것이 유리하다.
이제, 2가지의 임의 데이터를 추출하는 함수 2개를 만들어보자.
함수는 둘이 동일한 형태지만 자세히 보면 데이터를 추출하는 방식이 torch.rand( )와 torch.randn( )으로 서로 다르다.
def generate_random_image(size):
return torch.rand(size)
def generate_random_seed(size):
return torch.randn(size)
Discriminator에 투입할 때마다 generate_random_image(784)를 사용하고.
Generator에는 generate_random_seed(100)을 사용하면 된다.
아래는 위의 내용을 바탕으로 변경된 코드의 부분들이다.
자연적으로 형성된 임의의 숫자에 대해 Discriminator의 성능을 테스트한다.
# %%time
D = Discriminator().to(device)
for label, image_data_tensor, target_tensor in mnist_dataset:
# real data
D.train(image_data_tensor, torch.FloatTensor([1.0]))
# arbitrary data
D.train(generate_random_image(784), torch.FloatTensor([0.0]))
# manually run discriminator to check it can tell real data from fake
for _ in range(4):
image_data_tensor = mnist_dataset[np.random.randint(0, 60000)][1]
print(D.forward(image_data_tensor.to(device)).item())
pass
for _ in range(4):
print(D.forward(generate_random_image(784).to(device)).item())
학습이 되지 않은 Generator가 올바른 형태의 출력을 반환하는지 확인한다.
G = Generator()
output = G.forward(generate_random_seed(100))
img = output.detach().numpy().reshape(28, 28)
plt.imshow(img, interpolation="none", cmap="Blues")
이때, 신경망의 효율적인 학습을 위해 Normalize된 Gaussian Distribution에서 100개의 값을 추출한다.
# %%time
# Discriminator & Generator 생성
D = Discriminator().to(device)
G = Generator().to(device)
# Discriminator & Generator 훈련
for label, image_data_tensor, target_tensor in mnist_dataset:
# 1단계: Discriminator Real-Image에 대해 훈련
D.train(image_data_tensor, torch.FloatTensor([1.0]))
# 2단계: Discriminator Fake-Image에 대해 훈련
# Generator Weight Update X: detach() 함수를 사용
D.train(
G.forward(generate_random_seed(100).to(device)).detach(),
torch.FloatTensor([0.0]),
)
# 3단계: Generator가 만든 Fake-Image에 대해 Discriminator가 Real_Image로 착각하게끔 훈련
G.train(D, generate_random_seed(100), torch.FloatTensor([1.0]))
pass
이제 결과가 좋아졌는지 확인해보자.
6개의 subplot마다 서로 다른 숫자가 나타난 것처럼 보이기에, Mode Collapse문제가 해결된 것처럼 보인다.
지금까지 2가지의 성과를 이루었다.
- Generator가 Real Image를 보지 않고도 Training Dataset에서 나온 것처럼 생긴 숫자 Image를 생성하도록 훈련시켰다
- Arbitrary Random Seed를 바꿈으로써 하나의 Generator가 이제 여러 개의 숫자 Image를 생성할 수 있다.
이제 Loss Chart를 보면서 분석하자.
현재는 BCELoss( )를 사용했기 때문에 Loss 값들이 항상 0부터 1사이에 머물러 있지는 않는다.
따라서, plot_progress( )함수를 통해 Discriminator와 Generator 모두 손실 범위의 상한을 없애고 수평 격자선을 추가하자.
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=(0),
figsize=(16, 8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5, 1.0, 5.0),
)
다음 Graph는 Discriminator의 Loss에 대한 정보를 보여준다.
Loss가 빠르게 0으로 수렴하고 유지되지만, 때때로 Jump가 발생하는 것이 보인다.
이는 Discriminator와 Generator 사이의 균형이 맞지 않았다는 것을 의미한다.
다음 Graph는 Generator의 Loss에 대한 정보를 보여준다.
Loss가 처음에는 튀어오르는 것을 볼 수 있는데, 이는 Generator의 성능이 Discriminator의 성능에 뒤쳐진다는 뜻이다.
Loss가 다시 떨어진 이후에는 3 근처의 값으로 계속해서 진동한다.
MSELoss( )와 다르게 BCELoss( )의 최댓값은 1.0으로 제한되어 있지 않다는 점을 기억하자.
사실 이 Graph는 Generator의 Loss가 넓은 범위에 걸쳐 존재하기 때문에 살짝 아쉬운 결과이긴 하다.
다만 그래도 개선 전의 Graph보다는 확실히 나은 결과이다.
Generator의 Loss는 어느 정도 고정된 값(Fixed Value)을 중심으로 약간의 변화가 있는 게 오히려 더 바람직하다.
기존의 Loss Graph는 Discriminator의 Loss가 빠르고 깔끔하게 0으로 수렴했다.
또한 Generator의 Loss는 빠르고 깔끔하게 증가했다.
이러한 깔끔함은 이상적으로 보일지는 몰라도 우리가 이루고자 하는 목표와는 다르다.
오히려 Generator의 Loss는 fixed value를 중심으로 약간의 변화가 있는게 오히려 더 바람직하다.
그렇다면, 균형점에 도달했을 때 BCELoss( )가 과연 어떠한 값을 가질까?
아무 분류도 하지 못하는 분류기를 통과했을 대 예상되는 Loss는 $- ln (0.5) = ln (2) = 0.693$ 이다.
즉, 균형점에 도달하는 모델임을 알고 신경망을 돌리면, Discriminator와 Generator의 Loss 모두 0.69 정도로 수렴한다.
분명 Mode Collapse 문제는 해결했으나, 이미지 자체의 Quality는 그다지 높지 않다.
이를 해결하기 위해 Training time과 epoch을 더 늘려 돌려보는 방법을 실행하자.
GAN 훈련문의 바깥에 반복을 추가하여 여러 epoch을 돌려보자.
필자의 Mac Air는 너무 오래 걸려서...Colab 가상환경에서 V100을 이용하여 8 epoch으로 돌려봤다.
%%time
# Discriminator & Generator 생성
D = Discriminator().to(device)
G = Generator().to(device)
# Discriminator & Generator 훈련
for _ in range(8):
for label, image_data_tensor, target_tensor in mnist_dataset:
# 1단계: Discriminator Real-Image에 대해 훈련
D.train(image_data_tensor, torch.FloatTensor([1.0]))
# 2단계: Discriminator Fake-Image에 대해 훈련
# Generator Weight Update X: detach() 함수를 사용
D.train(
G.forward(generate_random_seed(100).to(device)).detach(),
torch.FloatTensor([0.0]),
)
# 3단계: Generator가 만든 Fake-Image에 대해 Discriminator가 Real_Image로 착각하게끔 훈련
G.train(D, generate_random_seed(100), torch.FloatTensor([1.0]))
pass
Q. Generator의 Input seed에 torch.randn( )을 사용한 것이 궁극적인 해결책인가?
만약 그렇다면 초반에 GAN 성능 향상시키기 (1)의 4가지 방법은 필요 없어도 되는거 아닌가?
A. 그렇지는 않다. 최초 형태의 GAN에 torch.randn( )함수만 넣으면 Mode Collapse가 해결되지는 않는다.
여러 기법들을 종합적으로 동원하여 해결한 것이다.
즉, Generator의 input에 generate_random_seed(100)만을 사용한다고 해서 해결되지는 않는다는 것이다.
앞에서 간단한 1010 패턴을 생성하는 GAN 모델에서 목격했던 Discriminator와 Generator 간의 균형이 왜 현재의 GAN 모델에서는 나타나지 않는지에 대해 궁금할 수 있다.
Loss Graph에 따르면 Discriminator의 Loss는 0으로 급격하게 떨어진 후에는 유지되고, Generator의 손실은 굉장히 높게 머물러있다.
하지만 실제 Production 수준에서 GAN은 이정도의 균형을 맞추기 어렵다. 균형을 맞추지 않더라도 Generator가 어느정도 괜찮은 품질의 이미지를 생성하는 것을 볼 수 있다.
물론 균형을 조금 더 개선할 수 있다면 당연히 시도하는 것이 맞다.
이제 우리는 계속해서 Loss Graph 를 통해 훈련이 어떻게 진행되는지 확인할 것이다.
예를 들어, MNIST Loss Graph는 훈련이 불안전하거나, 혼란에 빠지지 않나 모니터링하는 데 많은 도움을 준다.
2.3) Seed로 실험하기
지금까지는 Generator와 Discriminator의 Input에 임의의 숫자를 투입하는 Seed만을 생각했다.
이제는 GAN 훈련 후 seed의 몇 가지 특성에 대해 알아보고자 한다.
두 개의 서로 다른 seed: seed1, seed2 가 있다고 가정하자.
우리는 이 2개의 seed에서 이미지를 만들어낼 수 있다.
그럼 seed1과 seed2의 중간 정도의 값을 가지는 seed를 한 번 생각해보자.
생성된 image는 어떠한 형태를 띄는가? seed1, seed2 사이에서 일정한 간격으로 시드를 생성한다면 어떠한 결과가 나올까?
아래의 코드는 임의의 시드를 하나 택하여 seed1 변수에 저장하는 코드이다. 또한 seed에서 생성된 이미지를 그리는 역할도 한다.
seed1 = generate_random_seed(100)
out1 = G.forward(seed1.to(device))
img1 = out1.detach().cpu().numpy().reshape(28, 28)
plt.imshow(img1, interpolation="none", cmap="Blues")
아래의 코드는 동일한 역할을 하지만 시드는 seed2를 사용한다.
seed2 = generate_random_seed(100)
out2 = G.forward(seed2.to(device))
img2 = out2.detach().cpu().numpy().reshape(28, 28)
plt.imshow(img2, interpolation="none", cmap="Blues")
위의 결과들을 보면, 이미지들은 각각 3 그리고 9의 형상을 띄고 있음을 알 수 있다.
이제 seed1과 seed2 사이의 일정한 간격의 12개 시드를 구하는 코드를 작성해보자.
count = 0
# 3행 4열로 생성된 이미지 출력
f, axarr = plt.subplots(3, 4, figsize=(16, 8))
for i in range(3):
for j in range(4):
seed = seed1 + ((seed2 - seed1) / 11) * count
output = G.forward(seed.to(device))
img = output.detach().cpu().numpy().reshape(28, 28)
axarr[i, j].imshow(img, interpolation="none", cmap="Blues")
count += 1
pass
pass
결과에서 볼 수 있듯이 3은 9를 향해 진화하고 있으며, seed1에서 seed2를 향해 조금씩 숫자가 변하고 있다.
그렇다면 시드끼리 더하면 어떠한 결과가 일어날까?
seed = seed1 + seed2
out3 = G.forward(seed.to(device))
img3 = out3.detach().cpu().numpy().reshape(28, 28)
plt.imshow(img3, interpolation="none", cmap="Blues")
결과는 3처럼 보이기도 하고 9처럼 보이기도 한다. 이는 3과 9가 동시에 겹쳐져 나왔기 때문이다.
즉 시드끼리의 합은 각 시드에 대응하는 이미지를 합친 이미지를 생성해낸다는 점을 알 수 있다.
만약 시드끼리 뻬면 어떠한 결과가 나올까?
seed = seed1 - seed2
out4 = G.forward(seed.to(device))
img4 = out4.detach().cpu().numpy().reshape(28, 28)
plt.imshow(img4, interpolation="none", cmap="Blues")
괴상한 이미지가 결로 나온다. 3에서 9가 겹치는 부분을 뺀 결과는 아닌 것 같다.
즉 시드가 동작하는 방식은 단순 사칙연산과 같이 간단하지 않다는 것이다.
3. 핵심 정리
1. 단색 이미지(Grayscale, e.g. MNIST)를 대상으로 작업하는 것은 신경망 디자인을 바꿀 필요가 없다.
2차원 행렬의 pixel 값들은 아주 쉽게 1차원 행렬의 값으로 바꿀 수 있으며, 이를 판별기에 입력하면 된다.
pixel의 순서는 중요하지 않으며, 일관성만 있으면 된다.
2. Mode Collapse는 다른 선택지가 있어도 계속해서 하나의 Class로만 Generator가 숫자를 생성하는 현상을 말한다.
모드 붕괴는 GAN 훈련에서 가장 까다로운 문제이다.
이러한 현상이 일어나는 이유나 해결하기 위한 방법은 아직까지도 활발하게 연구되고 있다.
3. GAN을 설계하기 위한 가장 좋은 시작점은
Generator와 DIscriminator를 동일한 구조로 만들어서 한쪽이 과도하게 성능이 좋아져버리는 현상을 방지하는 것이다.
4. Training에서는 양보다 질이 중요하다.
5. 두 개의 seed 값 사이의 seed 숫자는 중간 정도로 보간된 이미지가 나오는 것을 확인했다.
seed끼리 더한 합에서 도출된 이미지는 두 이미지를 합친 형태이나,
차이에서 도출된 이미지는 두 이미지의 겹친 부분을 제거한 것이 아니라 어떠한 직관적인 패턴을 따르지 않는다.
6. MSELoss의 경우 Loss의 이상적인 값은 0.25이며, BCELoss의 경우 $ln(2)$이며 이는 약 $0.693$ 정도이다.