1. Chapter 9 - Convolutional GAN
Chapter 9에서는 Chapter 8까지 만들어온 CelebA GAN을 기반으로 아래의 2가지 문제를 중심적으로 해결하는 모델을 만들 것이다.
- 이미지가 약간 불명확하게 보인다. 부드럽게 연결되어야 할 공간들이 고대비 pixel들로 채워져 있다.
- FC Layer는 꽤나 많은 메모리를 차지한다. 어느정도 큰 이미지를 대상으로 훈련한다면 GPU의 한계를 넘어서 훈련이 어려울 수 있다.
많은 소비자용 GPU는 구글 코랩이 제공하는 장비인 T4 or P100보다 적은 메모리를 가지고 있다.
1.1) Memory Usage
새로운 Convolutional GAN 구조를 짜기 전에, 앞서 만든 CelebA GAN이 Memory를 얼마나 소비하는지 확인해보자.
Jupyter Notebook을 다시 실행하면, Discriminator와 Generator는 Memory를 다시 사용하기 시작한다.
Neural Network를 따라 흐르는 입력과 정보의 양,
출력과 학습 파라미터에 따라 요구되는 GPU Memory의 크기가 달라진다.
아래의 코드를 사용하여 메모리가 얼마나 소비되고 있는지 확인할 수 있다.
# 텐서에 할당된 메모리 사용량 확인
torch.cuda.memory_allocated(device) / (1024 * 1024 * 1024)
0.7158570289611816
CelebA GAN 코드가 모두 실행된 상태에서 아래에 셀들을 추가해보자.
현재 GPU Memory의 약 0.7GB가 텐서에 할당된 상태라는 것을 알 수 있다.
이 Memory에는 Discriminator와 Generator 객체 안의 데이터가 남아 있으며, 다음에 다시 사용될 수 있으므로 사라진 상태는 아니다.
다만 현재 출력된 Memory 사용량 숫자가 훈련에 필요한 모든 Memory 양을 표시하는 것은 아니다.
일부 Memory 공간은 훈련이 완료된 이후 비워졌기 때문이다.
따라서 아래의 코드를 이용하여 실행 도중 도달된 최대 Memory 사용량을 알 수 있다.
# 실행 중 최대 메모리 사용량 확인 (GB 단위)
torch.cuda.max_memory_allocated(device) / (1024 * 1024 * 1024)
0.9795465469360352
확인해보니 역시 최대 메모리 사용량은 상당히 크다.
Memory 사용량과 관련된 구체적인 수치를 더 자세히 확인하고 싶다면 아래의 코드로 확인해보자.
# 메모리 사용량 요약
print(torch.cuda.memory_summary(device, abbreviated=True))
|===========================================================================|
| PyTorch CUDA memory summary, device ID 0 |
|---------------------------------------------------------------------------|
| CUDA OOMs: 0 | cudaMalloc retries: 0 |
|===========================================================================|
| Metric | Cur Usage | Peak Usage | Tot Alloc | Tot Freed |
|---------------------------------------------------------------------------|
| Allocated memory | 750630 KiB | 1003 MiB | 12847 GiB | 12847 GiB |
|---------------------------------------------------------------------------|
| Active memory | 750630 KiB | 1003 MiB | 12847 GiB | 12847 GiB |
|---------------------------------------------------------------------------|
| Requested memory | 747435 KiB | 998 MiB | 12816 GiB | 12815 GiB |
|---------------------------------------------------------------------------|
| GPU reserved memory | 1062 MiB | 1062 MiB | 1062 MiB | 0 B |
|---------------------------------------------------------------------------|
| Non-releasable memory | 13273 KiB | 15622 KiB | 561751 MiB | 561738 MiB |
|---------------------------------------------------------------------------|
| Allocations | 57 | 78 | 3660 K | 3660 K |
|---------------------------------------------------------------------------|
| Active allocs | 57 | 78 | 3660 K | 3660 K |
|---------------------------------------------------------------------------|
| GPU reserved segments | 16 | 16 | 16 | 0 |
|---------------------------------------------------------------------------|
| Non-releasable allocs | 18 | 21 | 2472 K | 2472 K |
|---------------------------------------------------------------------------|
| Oversize allocations | 0 | 0 | 0 | 0 |
|---------------------------------------------------------------------------|
| Oversize GPU segments | 0 | 0 | 0 | 0 |
|===========================================================================|
위의 표에서 내가 관심있는 부분은, 현재(Cur Usage - Allocated Memory) 와 최대 메모리(Peak Usage - Allocated Memory) 사용량이다.
이 수치들을 이용하여 함수를 개량하면 Memory가 얼마나 줄어드는지 확인할 수 있고, 이를 통해 효율적인 구조로 개선이 가능하다.
1.2) Localized Image Features
Machine Learning의 핵심 아이디어는 가능한 한 '아는 모든 지식'을 동원하여 문제를 풀어야 한다는 것이다.
즉 Domain Knowledge를 활용함으로써, 유효하지 않다는 걸 아는 방법들을 미리 배제해 문제의 크기를 줄일 수 있다.
그럼 학습 파라미터들은 잘 작동하는 조합들을 더 쉽게 찾아나갈 수 있어서 ML이 더 수월해진다.
이를 Image에 적용해보자. Machine Learning을 위한 유의미한 특성은 Localized Features (지역화된 특성) 이라는 것을 알 수 있다.
눈과 코를 나타내는 pixel은 가까이 있을 수 밖에 없다.
이러한 정보를 Image가 얼굴임을 분류할 때 중요하게 작동한다.
즉, 직관 (Domain Knowledge)을 사용하여 Localized Feature들을 조금 더 고려하도록 Neural Network를 구성할 수 있다.
이전에 구성한 MNIST, CelebA Discriminator의 경우 위와 같이 Domain Knowledge 를 반영하지 않았다.
오히려 FC Layer를 이용하여 Image 상의 모든 pixel을 고려했다.
그러나 Image의 Localized Feature들을 고려하지 않았기 때문에 훈련을 조금 더 무식하게 진행한 면이 있다.
1.3) Convolutional Filter
FC Layer 일 때의 단점
- 2차원의 데이터를 1차원으로 Flatten시켜서 모든 pixel에 가중치를 부여함
한 픽셀마다 너무 세세하게 본다
주어진 이미지에만 너무 학습하여 조금의 변화만 주어도 예측해내지 못함
- 각 pixel들 위치를 서로 바꿔서 학습하는 것과 다를게 없음: Localized Feature 학습 불가
CNN의 장점
- 신경 다발 (Connections)을 잘 끊어냄
이미지를 인식할 때 뇌의 일부분만 활성화된다는 실험 결과
그렇기 때문에 Input Layer에서 모든 Pixel에 가중치를 부여하는 것이 좋지 않다.
CNN은 일부 Pixel에 동일한 가중치(Weight)을 공유하며 학습한다.
- 위치별 특징을 추출함
위치 정보를 유지한 채로 특정 Pattern(특징)을 찾음
Convolution 연산은 위치별 패턴을 찾는 연산
다음은 Convolution Operation에서 사용되는 Filter, Kernel에 대해 자세히 설명하겠다.
1) Filter
- 단일(1개) Filter는 3D(3차원) tensor: $F \times F \times C$
- 각 Filter는 여러 개의 Kernel로 구성되어 있으며 개별 Kernel은 Filter 내에서 서로 다른 값을 가질 수 있다
- Kernel의 개수 = Channel의 개수: Channel = Kernel
- CNN에서는 이러한 3차원 Filter K개를 각각 Input Feature Map에 적용한다
2) Input/Output Feature Map과 Filter의 Channel 개수 $C$/Filter 개수 $K$ 개의 관계
입력 Feature Map($Shape: H \times W \times C$) Channel 수 C = Convolution 연산을 적용할 Filter($Shape: F \times F \times C \times K$)의 Channel 수 C
- Channel 수가 동일해야 Convolution 연산이 가능
- Input Feature Map의 각 Channel에 단일 FIlter 기준 Kernel을 분배하여 Convolution 연산을 수행
출력 Feature Map($Shape: H \times W \times C$) Channel 수 C = Convolution 연산을 적용할 Filter($Shape: F \times F \times C \times K$)의 Filter 수 K
- FIlter 1개당 Feature Map 1개 (1:1 대응)
- Batch Size를 제외한: Input Image Data, Feature Map 모두 3-Dimension
- Each Filter도 3-Dimension
- Input Image Data는 RGB이므로 ($H \times W \times 3$) 3-Dimension이다
- 하나의 단일 Filter는 여러 개의 Kernel(Channel)을 가진다: $F \times F \times C$
- 그리고 여러 개의 Kernel 개수 $K$ = Channel 개수 $C$이므로 Channel = Kernel
모델을 만들 때, Conv2D 연산을 보면 단일 Filter의 Channel 개수 $C$가 나와있지 않다.
이는 Input Tensor의 Channel 수와 Conv2D의 Channel 수가 무조건 같을 것이라고 생각을 하는 것이다.
그렇기에 단일 Filter의 Channel 수 $C = 1$이다. (28, 28, 1)
그리고 첫번째 Conv2D 연산의 Output은 Filter의 개수만큼인 $K$가 32가 나온다. (28, 28, 32)
이후 두 번째 Conv2D 연산을 적용하기 위해서는 두 번째 Conv2D 단일 Filter의 Channel 수는 $C = 32$여야 한다.
단일 Filter에 32개의 Kernel이 들어가 있는 것으로 적용이 된다고 볼 수 있다.
위의 내용들은 모두 생략이 되면서 계산이 수행된다. 당연히 맞아야 Convolution 연산이 가능하기 때문이다.
Padding $P = 1$, Stride $S = 1$이라고 하자.
$3 \times 3 \: Kernel$, $depth = 1$인 단일 Filter가 32개 있는 Layer
- $320 = (3 \times 3 \times 1 + 1) \times 32$
$3 \times 3 \: Kernel$, $depth = 32$인 단일 Filter가 64개 있는 Layer
- $18496 = (3 \times 3 \times 32 + 1) \times 64$
3) Input Feature Map w/ Multiple Channels에 Convolution Filter 적용 Mechanism
Input Feature Map의 Channel 수 $C$ = Filter의 Kernel(Channel) 수 $C$는 무조건 같아야 한다.
Filter의 개수 $K=1$ 이므로 Output Feature Map의 Channel 수 $C=1$이다.
4) Input Feature Map w/ Multiple Channels에 $3 \times 3$ Filter 적용 Mechanism
5) Input Feature Map w/ Multiple Channels에 여러개의 Filter 적용
Output Feature Map의 Channel 수 $C$ = Conv를 적용한 Filter의 수 $K$
절대로 단일 Filter의 Kernel(Channel)수 $C$가 아니다!
왜냐하면 FIlter 한 개당 Feature Map이 한 개씩 1:1 대응되어 나오기 때문이다.
Input Feature Map의 Channel 개수가 3이면 당연히 동일하게 앙옆으로 계산하므로 단일 Filter의 Kernel 개수는 3이다.
6) Filter, Kernel, Channel 수
Filter의 개수: 128개
Kernel의 크기: $3\times 3$
Filter의 Channel수: 3개 (input에 들어있는 Channel 수와 동일)
Output Feature Map의 Channel 수: 128개 (Filter의 개수와 동일)
7) Filter의 shape
위의 두 Network에서 각 Layer에 적용된 Filter의 shape은?
- $5 \times 5 \times 1$, $5 \times 5 \times 6$
- $11 \times 11 \times 3$, $5 \times 5 \times 96$
첫번째는 피쳐맵의 채널이 6개(6x)가 있고 colvolution 연산은 5x5 이다.
고로 Filter의 개수는 6개이다.
Filter의 size는 5x5
input의 채널 수는 1개이다.
따라서 (5x5x1)의 Filter 6개가 연산이 된다.
두번째는 convolution 연산도 5x5로 계산 된다.
단일 Filter에 들어가는 채널 개수는 당연히 6개이다.
따라서 (5x5x6)의 Filter 16개가 연산이 된다.
첫번째는 11x11 커널 사이즈를 가지는 걸로 convolution 연산을 진행한다.
여기서 단일 Filter의 채널수는 3개이다.
그리고 Filter의 개수는 96개이다.
왜냐하면 output으로 96개가 나왔기 때문이다.
따라서 (11x11x3)의 Filter 96개가 연산이 된다.
두번째는 5x5 커널 사이즈를 가지는 걸로 convolution 연산을 진행한다.
패딩은 same을 주고 단일 Filter의 채널은 96개이다.
그리고 Filter는 256개이다.
따라서 (5x5x96)의 Filter 256개가 연산이 된다.
CNN에 관한 추가적인 설명은 아래의 블로그 포스팅을 확인하면 좋을 것 같다.
https://jwk0302.tistory.com/134
1.4) Kernel Weight 학습하기
위에서 언급한 Convolutional Kernel이 훈련에 어떻게 유용하게 사용되는지 알아보자.
앞에서 다양한 Kernel이 Image에서 Pattern을 추출해낼 수 있다는 것을 살펴봤는데 (CNN = 위치별 패턴을 찾는 연산),
이러한 정보(위치별 Pattern)는 그 이미지를 분류할 때 굉장히 유용하다.
예를 들어, 아래쪽 중앙에 수평의 어두운 특성이 있고
왼쪽 상단 및 오른쪽 상단에도 어두운 특성이 있다면 얼굴이라고 판단할 수 있다.
이렇게 되면, Image와 Kernel 간 가중치를 학습하여 중요성이 높은 Kernel들을 선별할 수 있게 된다.
더 나은 방법은 이미 만들어진 Kernel을 선별하는 것보다 (Top-Down), 훈련시 제일 좋은 점수를 내는 Kernel (Bottom-Up)을 사용하는 것이다.
PyTorch를 비롯한 많은 Machine Learning Framework로부터 사용되고 있는 방법이다.
즉, 훈련을 통해 이미지의 특징을 가장 잘 골라낸(최적의 가중치) 커널들을 얻게 되고, 여기서 얻은 정보들을 결합하여 신경망은 이미지를 분류하게 된다.
다만 모든 Kernel이 다 쓸모 있는 건 아니고, 이러한 Kernel에 대해서는 가중치를 낮춰 그 영향력을 낮춘다.
1.4) 특성의 계층구조
Input Image를 Convolutional Layer에 적용하여 나온 요약된 정보를 Feature Map이라 한다.
이러한 Feature Map에 연속적으로 Convolutional Layer를 적용하여 조금 더 추상적인 특성들을 조합으로 이루어진 High-Level Feature들을 얻을 수 있다.
예를 들어, 모서리와 점(Low-Level Feature)을 적당히 조합한다면 눈이나 코(High-Level Feature)를 얻을 수 있다.
여기에 더 높은 고수준의 특성들을 찾기 위해 Convolutional Layer를 또 적용할 수 있다.
눈과 코를 특성을 조합하여 얼굴을 나타내는 식의 행위로 볼 수 있다.
결국 이웃한 pixel들의 작은 패턴에서 비롯된 중수준의 특성으로부터 고수준의 이미지 내용을 만들어나가는 이러한 방식이 이미지 분류를 더 효과적으로 만든다.
이를 Convolutional Neural Network이라 하며, Image Classification에서 사용되는 최신의 기술이다.
즉, 핵심은 Kernel의 weight을 인간이 지정하는 것이 아니라, Neural Network가 훈련을 통해 알아서 지정하게끔 한다는 것이다.
CNN을 통해 Network가 제일 유용한 Low-Level ~ High-Level Feature들을 알아서 배우도록 하는 것이다.
2. MNIST Classifier (CNN)
CNN에 익숙해지기 위해 GAN 모델을 사용하기 전에, CNN을 활용한 MNIST Classifier를 만들어보자.
self.model에서 FC Layer 부분을 CNN으로 바꾸자.
# classifier class
class Classifier(nn.Module):
def __init__(self):
# initialise parent pytorch class
super().__init__()
# define neural network layers
self.model = nn.Sequential(
nn.Linear(784, 200),
#nn.Sigmoid(),
nn.LeakyReLU(0.02),
nn.LayerNorm(200),
nn.Linear(200, 10),
nn.Sigmoid()
#nn.LeakyReLU(0.02)
)
Classifier의 self.model 부분만 변경하면 되기 때문에 Constructor 만 가져왔다.
우선, CNN은 2차원의 이미지에 대해 적용하는 것인데, 앞서 만든 FC Layer는 Pixel 값을 1차원 리스트로 만들어 투입했다.
즉, 우리는 들어가는 image_tensor_data를 (28, 28) shape으로 바꿔서 Discriminator에 input으로 넣어주면 된다.
결과적으로 Discriminator에 들어가는 MNIST Data는 (1, 1, 28, 28)의 shape을 가진다.
PyTorch의 CNN Filter는 (batch_size, channels, height, width)를 받으로 4차원 Tensor를 이용한다.
여기서 batch_size는 1로 지정되고, MNIST는 GrayScale이므로 channels 또한 1이다.
height, width 모두 28이므로 결국 MNIST Data를 (1, 1, 28, 28) 형태로 가지도록 바꾸면 된다.
따라서 Convolutional Layer를 사용한 MNIST Classifier 구조는 다음과 같다.
class View(nn.Module):
def __init__(self, shape):
super(View, self).__init__()
# shape가 정수일 경우에만 튜플로 변환
self.shape = (shape,) if isinstance(shape, int) else shape
def forward(self, x):
return x.view(*self.shape)
# classifier class
class Classifier(nn.Module):
def __init__(self):
super(Classifier, self).__init__()
def block(in_channels, out_channels, kernel_size, stride, normalize=True):
layers = [nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, stride=stride)]
if normalize:
layers.append(nn.BatchNorm2d(out_channels))
layers.append(nn.LeakyReLU(0.02, inplace=True))
return layers
self.model = nn.Sequential(
*block(1, 10, 5, 2), # 1 * 1 * 12 * 12
*block(10, 10, 3, 2), # 1 * 10 * 5 * 5
View(10 * 5 * 5), # 1 * 250
nn.Linear(250, 1),
nn.Sigmoid()
)
nn.Cov2d라는 Convolutional Layer는 (in_channels, out_channels, kernel_size, stride, padding) 등을 paramter로 받는다.
처음 input channel의 개수는 Grayscale image 이미지이므로 in_channels = 1 이다.
출력되는 output channel의 개수는 10으로 지정했기에, out_channels = 10 이다.
in_channels는 input feature map의 channel 개수이고,
out_channels는 filter의 개수이자 output feature map의 channel 개수이다.
Training Loop 이후로 나오는 모든 image_tensor_data에 대해 (1, 1, 28, 28)로 reshape 해준다.
image_tensor_data.view(1, 1, 28, 28)
%%time
# create neural network
C = Classifier()
# train network on MNIST data set
epochs = 3
for i in range(epochs):
print('training epoch', i+1, "of", epochs)
for label, image_data_tensor, target_tensor in mnist_dataset:
C.train(image_data_tensor.view(1, 1, 28, 28), target_tensor)
pass
pass
kernel_size는 말 그대로 square kernel의 height = width 이다.
처음에 2 x 2, 나중에는 5 x 5를 사용했다.
stride는 kernel는 shift하는 간격이다. 즉, 이미지를 따라 kernel이 얼마나 많이 움직이는지에 대한 설정이다.
stride는 두 번 다 2로 고정시켰다.
2번의 Convolution을 통해 각 Feature Map의 크기는 12 x 12, 5 x 5로 Down-Sampling되었다.
Conv/FC -> (BN) -> ReLU(Other Activation) -> (Dropout) -> Conv/FC 의 순서로 보통 작용한다.
일반적으로 BN과 Dropout은 같이 사용하지 않는다고 한다.
이후 Layer의 전 channel에 걸쳐서 Normalization을 적용하는 BatchNorm2d( )를 사용하고,
Activation Function으로 LeakyReLU를 사용한다.
그 다음 nn.Sequential( ) 안에서 tensor shape을 변경해주기 위해 View( )라는 Helper Class를 따로 정의하여
FC Layer의 in_feature 개수를 250으로 맞춰주고, out_feature 개수를 10으로 맞춰준다.
out_features = 10인 이유는 MNIST Class가 10개이기 때문이다.
이제 CNN 기반의 MNIST Classifier를 돌려보자.
성능은 약 98%(97.97%)으로 이전 기록이었던 97% 보다는 높아졌다.
엄청나게 높아진 것은 아니지만, MNIST 분류기가 98%를 넘는 성능을 내는 것은 쉽지 않다.
아주 간단한 설계와 적은 양의 코드로도 98%의 성능을 기록한 것이다.
3. Convolutional GAN
앞서 CNN Layer를 이용하여 Classifier를 만들어 보았으니, 이제부터는 GAN을 다뤄보자.
즉, Convolutional Operation을 이용한 CelebA GAN 모델을 구현해보는 것이다.
3.1) CelebA CNN
CelebaA 이미지는 $217 \times 178$ pixel 크기의 사각형이다.
작업을 단순화시키기 위해 $128 \times 128$ 이미지로 바꾸는데, 제공된 이미지 중앙을 기준으로 잘라내(CenterCrop) 사용할 것이다.
아래의 코드를 통해 numpy image 행렬을 받아 중앙에서부터 주어진 크기에 맞게 잘라주는 함수를 작성했다.
def crop_center(img, new_width, new_height):
height, width, _ = img.shape # H x W x C
startx = (width // 2) - (new_width // 2)
starty = (height // 2) - (new_height // 2)
return img[starty : starty + new_height, startx : startx + new_width, :]
crop_center(128, 128)을 이용해서 정사각형을 잘라 $128 \times 128$ 크기로 img라는 이름으로 저장한다.
crop_center( )함수는 Dataset Class 정의에서 사용하기 때문에 그 위에서 정의한다.
그 다음 CelebADataset에서 __getitem__( )과 plot_image( ) Method를 수정한다.
두 함수 모두 이미지를 HDF5 데이터셋에서 가져와 128 x 128 크기의 정사각형 모양으로 자른다.
class CelebADataset(Dataset):
def __init__(self, file_path):
super(CelebADataset, self).__init__()
self.file_object = h5py.File(file_path, "r")
self.dataset = self.file_object["img_align_celeba"]
pass
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
if idx >= len(self.dataset):
raise IndexError(f"index {idx} is out of range")
img = np.array(self.dataset[str(idx) + ".jpg"])
img = crop_center(img, 128, 128)
return torch.cuda.FloatTensor(img).permute(2, 0, 1).view(1, 3, 128, 128) / 255.0
def plot_image(self, idx):
img = np.array(self.dataset[str(idx) + ".jpg"])
img = crop_center(img, 128, 128)
plt.imshow(img, interpolation="nearest")
pass
pass
__getitem__()은 텐서를 반환해야 하며, 이 텐서는 (batch_size, channels, height, width) 형태의 4차원 tensor여야 한다.
현재 numpy 행렬은 (height, width, 3) 형태의 3차원 tensor이므로 permute(2, 0, 1)를 통해
numpy행렬 순서를 (3, width, height)로 바꿀 수 있고, 이어서 view(1, 3, 128, 128)로 배치크기 1을 추가해서 차원을 하나 더 추가한다.
이제 Dataset Class가 제대로 이미지를 Crop 했는지 확인해보자.
확실히 더 작아져서 $128 \times 128$ 크기의 정사각형 모양의 이미지가 나온 것을 알 수 있다.
이제 Discriminator와 Generator에 들어가는 Layer들을 생각해보자.
Network에 과연 몇 개의 Layer가 필요한건지, Hidden Layer에는 얼마나 많은 Kernel이 필요한건지 판단할 수 없다.
즉, 딱 떨어지는 답은 없다는 것이다.
최대한 간단한 Network를 만들면 훈련이 쉬워지지만, 너무 작으면 제대로 훈련이 되지 않을 것이다.
또한, Discriminator는 보통의 Classifier처럼 Data가 Layer를 통과하면서 줄어들게 설계하고,
Generator는 Data의 크기가 커지게끔 설계해야 한다.
총 3개의 Convolutional Layer를 사용할 것이다.
첫 번째 Convolutional Layer는 $8 \times 8 \times 3$ shape의 3D Filter가 256개 있다.
kernel_size=8, stride=2 이기 때문에
output feature map의 shape은 $61 \times 61 \times 256$이다.
두 번째 Convolutional Layer는 $8 \times 8 \times 256$ shape의 3D Filter가 256개 있다.
kernel_size=8, stride=2 이기 때문에
output feature map의 shape은 $27 \times 27 \times 256$이다.
마지막 Convolutional Layer는 $8 \times 8 \times 256$ shape의 3D Filter가 3개 있다.
kernel_size=8, stride=2 이기 때문에
output feature map의 shape은 $10 \times 10 \times 3$이다.
네트워크의 후반부로 갈 수록 항상 데이터의 크기가 줄어든다.
이후 총 300개의 in_features를 input으로 하여 FC Layer에 넣고, 1개의 out_features가 나오게끔 Discriminator 를 설계한다.
더 많은 Convolutional Layer들을 적용하여 데이터를 줄일 수 있겠지만,
300은 이미지의 중요 특성을 요약하여 FC Layer를 거쳐 결과로 뽑아내기에 충분할 만큼 작은 값이다.
이제 실제 코드로 Discriminator의 Network를 구현하자.
class Discriminator(nn.Module):
def __init__(self):
super(Discriminator, self).__init__()
def block(in_channels, out_channels, normalize=True):
layers = [nn.Conv2d(in_channels, out_channels, 8, 2)]
if normalize:
layers.append(nn.BatchNorm2d(out_channels))
layers.append(nn.LeakyReLU(0.2, inplace=True))
return layers
self.model = nn.Sequential(
# (1, 3, 128, 128) shape
*block(3, 256),
*block(256, 256),
*block(256, 256, normalize=False),
View(3 * 10 * 10),
nn.Linear(3 * 10 * 10, 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)
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, 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)
)
pass
이미 위에서 언급한대로 코드를 작성하였기에 추가로 설명할 부분은 없다.
Block 구조를 이용하여 코드의 확장성 및 유지보수성을 높였다.
이후, generate_random_image( )가 (1, 3, 128, 128) 형태의 4D tensor로 형태를 바꾸도록 수정해야 한다.
generate_random_image((1, 3, 128, 128) 을 사용한다.
%%time
D = Discriminator().to(device)
for image_data_tensor in celeba_dataset:
# real data
D.train(image_data_tensor, torch.cuda.FloatTensor([1.0]))
# fake data
D.train(generate_random_image((1, 3, 128, 128)), torch.cuda.FloatTensor([0.0]))
pass
아래의 Chart는 훈련시 Loss의 변화를 보여준다.
Loss가 0을 향해 굉장히 빨리 하락하는 것을 볼 수 있다.
이전에 훈련했던 MLP기반의 Network와 비교하면 매우 변동이 작고 노이즈가 적게 발생한 것임을 알 수 있다.
Discriminator를 이제 실제 이미지와 임의의 이미지에 대해 구별할 수 있는지 수동으로 실행해보고 점수를 비교해보자.
이때, 임의의 이미지를 생성하는 함수에 들어가는 tensor 형태를 (1, 3, 128, 128)으로 조정하자.
for i in range(4):
image_data_tensor = celeba_dataset[random.randint(0, 20000)]
print(D.forward(image_data_tensor.to(device)).item())
pass
for i in range(4):
image_data_tensor = generate_random_image((1, 3, 128, 128))
print(D.forward(image_data_tensor.to(device)).item())
pass
1.0
1.0
1.0
1.0
0.00012578086170833558
8.857811735651921e-06
2.984654565807432e-05
7.323008048842894e-06
점수가 극단적으로 낮게 나오는 것을 보아, 신경망이 굉장히 자신감 있고 효과적으로 작동하는 것을 볼 수 있다.
그럼 이제 Generator의 Network를 한 번 생각해보자.
대체로 Generator와 Discriminator의 성능을 밸런싱하기 위해 Discriminator Network의 구조를 뒤집어서 Generator를 설계한다.
그렇다면 Convolution의 역연산을 이용하여 Feature Map을 Up-Sampling해서 기존의 이미지와 같은 크기의 $128 \times 128$의 이미지를 얻을 필요가 잇다.
이를 Deconvolution 이라 하는데 자세한 설명은 아래에서 후술하겠다.
3.2) Deconvolution vs Transpose Convolution
데이터의 효율적인 연산을 위해 차원을 축소하는 과정을 'Downsampling'이라고 하고,
반대로 데이터의 크기를 역으로 늘리는 (차원을 확대하는) 과정을 'Upsampling'이라 한다.
Upsampling을 이용하면 Feature Map에서 시작하여 이미지를 생성할 수 있다. 이때, Upsampling 과정 중 추가로 데이터를 생성하는 방법도 여러가지가 있는데, 이를 보간법이라 한다.
보간법(interpolation)에는 크게 4가지의 종류가 있다.
1. Nearest Neighbor(Unpooling): 복원해야 할 값을 가까운 값으로 복제 (각 원소를 복사해서 넣음)
2. Bed of Nails(Unpooling): 복원해야 할 값을 0으로 처리 (1st 위치에 input을 넣고 나머지는 0으로 처리)
3.Max Unpooling: Max Pooling한 값의 position을 따로 기억해 놓았다가 해당 위치에 input을 복원
4. Transposed Convolution: Convolution을 통해 얻어낸 Feature Map과 원본 이미지를 각각 input 데이터와 정답 데이터로 삼아 학습을 통해 원본 이미지에 가깝게 새로운 이미지를 생성
Upsampling은 다양하게 활용가능하다. 예를 들어, 저해상도의 이미지를 고해상도로 변환하거나 (High-Resolution)
전체 이미지에서 추출한 Feature Map 부분을 강조하는 효과를 낼 수도 있고,
임의의 이미지에 특정한 패턴을 입힐 수도 있다.
새로 생성한 이미지를 가지고 모델을 더 잘 훈련시킬 수도 있는데, 이게 GAN의 핵심 아이디어이기도 하다.
Transpose Convolution
Transpose Convolution (이하 TC)를 활용하면 엄밀한 의미의 deconvolution까진 아니지만 비슷한 효과를 얻을 수 있다.
TC 말고도 여러 가지 deconvolution 방법론이 있지만 그 자체로 하나의 분야이므로 여기서는 자주 언급되는 transpose convolution만 간단하게 살펴보자.
TC의 기본 아이디어는 Convolution을 통해 얻어낸 Feature Map과 원본 이미지를 각각 input 데이터와 정답 데이터로 삼아 학습을 통해 원본 이미지에 가깝게 새로운 이미지를 생성하는 것이다.
바로 여기서 Deconvolution과 개념적 차이가 생긴다.
Deconvolution 연산은 합성곱 연산 과정에서 사용한 커널을 그대로 재사용한다. 즉, 학습 과정이 일어나지 않는다.
반면 Transpose Convolution은 연산 과정에서 커널 값이 업데이트된다.
위 그림을 보면 Transpose Convolution 과정을 쉽게 이해할 수 있다.
파란색이 input(Original Image)이고 청록색이 output(Feature Map) 이미지이다.
합성곱 연산 과정을 역으로 따라가기 때문에 TC 연산에 사용되는 필터의 크기는 합성곱 연산에서 사용한 필터 크기와 같아야 한다.
연산 과정을 자세히 살펴보면 피처 맵의 각 원소 주변을 보간하여 input 데이터로 사용하는 것을 알 수 있다.
3.3) CelebA GAN
Generator의 구조는 다음과 같다.
기존에 있던 MLP Network를 CNN기반의 Network로 Upsampling 시켰다.
각각 kernel_size = 8. stride = 2의 구조로 Transpose Convolution이 이루어졌다.
1. 100개의 seed값을 입력으로 받아서 $3 \times 11 \times 11$개의 값으로 Mapping
2. 이를 View Class를 이용해서 (1, 3, 11, 11) 형태의 4D Tensor로 바꾼다
3. 이후 Convolution Transpose2d를 이용
- $(11 - 1) \times 2 + 8 = 28$, $(1, 256, 28, 28)$의 shape
- $(28 - 1) \times 2 + 8 = 62$, $(1, 256, 62, 62)$의 shape
- $(62 - 1) \times 2 + 8 - 2 = 128$, $(1, 3, 128, 128)$의 shape
4. Last Convolution Transpose2d에서 Padding을 1 추가하여 중간 격자에서 바깥쪽 테두리를 제거함
class Generator(nn.Module):
def __init__(self):
super(Generator, self).__init__()
def block(in_channels, out_channels, normalize=True):
layers = [nn.ConvTranspose2d(in_channels, out_channels, 8, 2)]
if normalize:
layers.append(nn.BatchNorm2d(out_channels))
layers.append(nn.LeakyReLU(0.2, inplace=True))
return layers
self.model = nn.Sequential(
# 1차원 행렬 입력
nn.Linear(100, 3 * 11 * 11),
nn.LeakyReLU(0.2, inplace=True),
# 4차원으로 형태 변환
View((1, 3, 11, 11)),
*block(3, 256),
*block(256, 256),
nn.ConvTranspose2d(256, 3, 8, 2, 1),
nn.BatchNorm2d(3),
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, D, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
g_outputs = self.forward(inputs)
d_outputs = D.forward(g_outputs)
loss = D.loss_function(d_outputs, targets)
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
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,
figsize=(16,8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5, 1.0, 5.0)
)
pass
마지막에 FC Layer를 추가하여 간단히 원하는 출력 크기로 맞추는 행위는 지양해야 한다.
Image는 Localized Feature로부터 생성되어야 하기 때문에, 모든 Pixel에 가중치를 부여하는 FC Layer는 적절하지 않다.
이후 훈련되지 않는 Generator가 올바른 형태로 임의의 pixel 값들을 생성하는지 먼저 확인해보자.
G = Generator().to(device)
output = G.forward(generate_random_seed(100).to(device))
img = output.detach().permute(0, 2, 3, 1).view(128, 128, 3).cpu().numpy()
plt.imshow(img, interpolation='none', cmap='Blues')
plt.show()
Generator가 만든 4D Tensor는 재배열해서 Chart를 그리기 쉬운 일반적인 형태로 바꿔야 한다.
permute(0, 2, 3, 1)을 이용해 (batch_size, channels, height, width)의 형태로 바꾸고
view(128, 128, 3)를 이용하여 (height, width, channels) 형태의 3D Tensor로 바꾼다.
훈련되지 않은 Generator는 제대로 된 크기의 이미지 (128, 128)을 만들어낸 것을 볼 수 있다.
Q. Generator는 이론상으로 임의의 pixel값에 해당하는 이미지를 생성하는 것으로 알고 있는데, 실제로는 무언가 체커보드와 같은 패턴을 띄는 것을 볼 수 있다. 또한 이미지 가장자리가 약간 더 어두워 보인다. 이는 왜 그런가?
A. Convolution Transpose를 수행할 때 Feature Map이 겹치는 부분이 있다.
즉, kernel size가 정확히 stride의 배수가 아닐 경우 image가 체커보드 형태의 패턴으로 나타날 수 있다.
또한 가장자리의 어두운 부분은 단순히 조금 덜 겹치는 구간이며, 이에 관여한 값들이 적은 것을 의미한다.
훈련을 거치면 알맞은 가중치를 학습해나갈 것이기에, 크게 의미있지는 않다.
그렇다면 이제 GAN을 1 epoch만 돌려보자. Training Loop에는 고칠 곳이 없다.
%%time
# Discriminator, Generator 생성
D = Discriminator().to(device)
G = Generator().to(device)
epochs = 1
for epoch in range(epochs):
print(f"epoch = {epoch + 1}")
# Discriminator와 Generator 훈련
for image_data_tensor in celeba_dataset:
# Discriminator 훈련: real data에 대해 학습
D.train(image_data_tensor, torch.cuda.FloatTensor([1.0]))
# Discriminator 훈련: fake data에 대해 학습
D.train(G.forward(generate_random_seed(100).to(device)).detach(), torch.cuda.FloatTensor([0.0]))
# Generator 훈련: Discriminator 속이기
G.train(D, generate_random_seed(100), torch.cuda.FloatTensor([1.0]))
pass
pass
아래의 Chart는 Discriminator / Generator 의 Loss가 Training이 진행됨에 따라 어떻게 변하는지 보여준다.
D.plot_progress()
Loss는 0으로 굉장히 빨리 수렴하며 계속해서 낮은 값에 머물러있다.
불안정하거나 혼란스러운 Loss가 보이지는 않지만, 손실이 이상적인 값인 0.693에 수렴하는 편이 더 낫기는 하다.
훈련 마지막 즈음에는 손실이 다시 상승하는 현상도 발견할 수 있다.
다음은 Generator의 Loss Chart이다.
G.plot_progress()
마찬가지로 어느정도는 안정되어 있는 편이다. Loss가 이상적인 값보다는 크지만 어느 정도의 속도로 떨어지고는 있다.
훈련 epoch을 늘리면 해결되는 문제일까?
Convolutional GAN은 epoch 1기준으로 어느정도 얼굴의 기본적인 부분을 만들어냈다.
이미지들에 눈, 코, 입이 달려있고 어느정도는 머리카락도 보인다.
품질이 그다지 좋지는 않지만 어느정도 내가 원하는 수준까지 이미지가 생성되었다.
이러한 이미지들은 Convolutional Layer의 Low, Medium, High - Level Feature들의 locality 특성을 이용하여 계층적으로 생성된 이미지들이다.
물론 이러한 특성들은 Training Data로부터 복사된 것이 아니다.
눈, 코, 입의 상대적인 위치가 잘 배열되어 있고, 어떻게든 Discriminator를 통과할 정도까지는 학습이 된 것이다.
또한 Mode Collapse는 보이지 않고, 어느정도 다양한 편이라 볼 수 있다.
이제 그럼 Convolutional GAN이 FC GAN보다 어느정도로 자원을 효율적으로 썼는지 메모리 사용량을 확인해보자.
torch.cuda.memory_allocated(device) / (1024 * 1024 * 1024)
0.15463495254516602
Jupyter Notebook이 Full로 실행되었을 때 약 0.15GB 정도가 할당되었다.
주된 이유는 DIscriminator객체와 Generator객체가 존재하기 때문이다.
Convolution GAN은 FC Layer GAN보다 약 4~5배 정도로 메모리를 적게 차지하는 것을 알 수 있다.
torch.cuda.max_memory_allocated(device) / (1024 * 1024 * 1024)
0.19135379791259766
Tensor의 최고 메모리 사용량을 살펴보면 약 0.19GB 정도인데, 이 역시 FC Layer GAN보다 약 5배 정도나 낮은 수치이다.
그럼 이제 epoch을 12로 하여 이미지를 조금 더 훈련해보자.
epoch을 조금 많이 돌렸더니, Mode Collapse가 일어나는 것을 볼 수 있다.
모든 이미지의 품질이 좋지는 않고, 훈련 횟수를 늘리는 것이 나을지도 확실하지는 않다.
즉, 설계한 간단한 네트워크 구조에는 어느정도 내제된 한계가 잇을 수도 있다.
위의 이미지들을 다시 살펴보면 Localized Patch가 하나씩 붙어 있는 것처럼 보인다.
예를 들어 한 쪽 눈은 다른 한 쪽에 비해 상당히 다른다든지,
머리카락의 반절은 모양이 다른 반쪽과 다른다든지 하는 현상이 관찰된다.
이러한 현상은 FC Layer 기반의 GAN에서는 그리 발생하지 않았던 현상으로, Convolutional GAN이 이전 Layer의 전체 이미지 정보를 사용하지 않고 각 특성을 이용하여 생성했기 때문이다.
즉 의도적으로 얼굴의 특정 부분에 집중하는 것은 장점도 크지만 단점도 있어 보인다.
3.4) GAN 개선하기
위의 Convolutional GAN을 개선하기 위해 여러가지 방법을 사용할 수 있다.
다른 종류의 Loss Function을 사용하거나, 표준적인 GAN 훈련 반복문에 변형을 시도해볼 수 있다.
또한 여러가지 측정 수치들을 바꿔가면서 모드 붕괴를 줄이기 위해 노력할 수도 있다.
혹은 GAN의 특성을 조금 더 반영하는 본인만의 Optimizer를 만들어볼 수도 있다.
책에서는 활성화 함수를 종류를 바꿔서 실험했다.
기존의 LeakyReLU 함수가 아니라 GELU(Gaussian Error Linear Unit) 함수를 사용했다.
해당 활성화 함수는 ReLU와 비슷하지만 조금 더 부드러운 곡선을 가지고 있다.
이 방법은 활성화함수가 좋은 기울기를 제공하기 위해서는
함수 자체에 첨점이 없어야 한다는 데에서 착안하여 제안되었다.
아래의 이미지들은 nn.LeakyReLU(0.2) 대신 nn.GELU( )를 사용한 결과이다.
epoch 8 기준으로 Mode Collapse도 없고, 구성한 네크워크의 복잡도에 비해 적당한 수준으로 잘 나온 것 같다.