Learning GAN

Learning GAN

2017, Jul 01    

Learning GAN

1. What is GAN?

딥러닝은 어디에 쓸까. 알파고나 자율주행차 등이 딥러닝 사례로 심심찮게 등장하지만, 게임 로그 분석을 하는 입장에서는 실제로는 성능 좋은 분류기로 사용하는 편이다. 그래서인지 Udacity Deep Learning Foundation 후반부에 나오는 GAN은 조금 막연하게 다가온다. 입력 데이터를 학습해 그와 유사하지만 기존에 존재하지 않는 완전히 새로운 데이터를 만들어내는 것은 확실히 재밌는 분야긴 하지만, 개인적으로나 업무적으로 아직 그 활용성이 와닿지는 않는 듯 하다. 어쨌든 배워놓으면 실이 될 것은 없고, 또 뒤에 나올 Semi-Supervised Learning에 GAN을 활용해먹을 수 있으니, 배운 내용을 간단히 남겨본다.

GAN은 Ian GoodFellow가 선보인 모델로, Generative한 모델이다. 예전에도 RNN을 사용해 텍스트를 만들어냈었는데, 이 방식은 단어나 문자를 한번에 하나씩 찍어내는 방식이었다. 이전에 만든 사례에서는 글자 시퀀스를 넣었을 때 그 다음에 오는 글자를 예측하는 식으로 모델이 작동했다. 이와 달리 GAN은 한방에 결과를 만들어내는 방식이다. 이미지를 예로 들자면, 픽셀을 연속적으로 찍어내는 것이 아니라 이미지를 한번에 찍어낸다.

2. GAN의 작동방식

GAN의 구조는 화폐위조범과 경찰의 사례로 쉽게 이해할 수 있다. 화폐위조범은 최대한 실제 화폐에 가깝게 위폐를 찍어내고, 경찰은 그런 화폐를 실제 화폐와 구분하는 역할을 수행한다 (범인을 실제로 잡지는 않는다). 범인은 처음에는 엉성한 화폐를 만들어 경찰에 쉽게 걸리지만, 걸린 결과를 피드백으로 받아 학습하여 점점 퀄리티를 개선해나간다. 개선된 위폐로 인해 경찰의 진폐 분류 확률이 0.5로 떨어지면, 즉 진짜를 분류할 수 없게되면 GAN의 목표를 달성하게 된다.

이같은 흥미로운 작동방식으로 인해 GAN은 2개의 뉴럴네트워크로 구성된다. 앞단인 Generator(화폐위조범)가 랜덤한 벡터인 Z로부터 가짜 데이터를 만든다. 만들어진 가짜 데이터는 진짜 데이터와 섞이고, Discriminator(경찰)가 이를 진짜와 가짜로 분류한다. Discriminator가 분류한 결과는 다시 네트워크를 거슬러 Generator를 학습시킨다.

즉, 데이터를 생성하는 과정은 입력값이었던 랜덤벡터 Z를 X 분포에 맵핑하는 과정이라 생각해볼 수 있다. Z를 계속 X에 집어넣다보면 어떤 분포가 만들어진다. Discriminator에서는 이 분포를 실제 값과 비교한다. 그리고 실제 값의 밀도가 가짜 값의 밀도보다 큰 경우, Discriminator는 가짜를 진짜에서 성공적으로 분류하게 된다. Generator는 이 비교값을 전달받아 학습하는데, 이로서 Generator가 만들어내는 X분포가 실제 분포쪽으로 점진적으로 이동하고, 학습이 완료되면 그 분포의 차이가 거의 없게 되는 것이다.

3. GAN 사용 팁

다른 딥러닝 모델과 마찬가지로 GAN도 튜닝을 잘 해줘야 잘 돌아간다. 아래는 Udacity 코스에서 소개한 몇가지 팁.

  • Genenator와 Discriminator에 각각 은닉층 한개 이상 둬야-
    • 그래야 두개 모델 모두 Universal Appromixator Property를 갖게 된다고 한다.
    • Generator는 실제 값의 분포를 모사해야 하고, Discriminator는 그 분포의 차이를 추론해야 하니, 이를 적절히 수행할 수 있는 Universal Approximation Function이 필요한 것으로 이해된다.
  • 히든레이어에는 보통 Leaky ReLU를 사용한다.
    • Relu는 음수를 0으로, 나머지는 x를 반환하는 함수다.
    • Leaky ReLU는 f(x) = max(x, ax)로 표현되는데, a를 0.01 정도로 작은 숫자를 넣는다.
    • 그렇게 되면 x가 양수라면 x가 반환되고, x가 음수라면, a를 곱해 매우 작아진 음수가 반환된다.
    • 결과적으로 해당 unit이 active 상태가 아니라도 약간의 gradient가 전달되게 된다.
  • Generator 아웃풋에는 tanh, Discriminator 아웃풋에는 sigmoid를 쓴다.
  • Discriminator loss를 계산할 때는 sigmoid를 통과하기 전인 logits을 쓸 것.
  • GAN에서는 레이블 스무딩을 사용한다.
    • 가짜 데이터는 0, 실제 데이터는 1로 레이블을 단다.
    • GAN에서는 더 안정적인 모델을 만들기 위해 1에 0.9 등을 곱해 조금 작게 만들어준다.
  • d_loss와 g_loss
    • d_loss는 Discriminator loss, g_loss는 Generator loss다.
    • GAN의 목표는 g_loss와 d_loss 모두 작게 만드는 것.
    • 직관적으로는 생성기의 성능이 좋을수록 분류기의 성능이 안좋아질 것이므로 서로 음의 관계라 할 수 있다.
    • 그러나 최적화를 시킬때 -(d_loss)를 g_loss로 쓰면 구현이 잘 안된다.
    • 궁극적으로 우리는 GAN이 d_loss와 g_loss를 모두 낮게 가져가길 원하므로, G를 학습시킬때는 레이블을 반대로 달아주고 최소 loss를 찾도록 학습시킨다.
  • 이미지를 생성할 때는 Convolutional Network를 반대로 구현하면 된다.
    • CNN 분류기는 3채널의 이미지를 점점 작게, 그리고 피쳐맵을 점점 많이 만든다.
    • 이와 반대로 이미지를 생성할때는 랜덤벡터Z로 구성된 매트릭스를 점점 넓고 피쳐맵을 좁게 만든다.
    • 즉 반대로 가는 개념. 이는 DCGAN을 실습할 때 더 살펴보기로 한다.
  • Batch Normalization
    • 꼭 해야 GAN이 제대로 동작한다.
    • DCGAN의 저자는 Generator의 마지막 레이어와 Discriminator의 인풋 레이어를 제외한 모든 레이어에 사용하라 권한다.
    • 생성된 데이터와 실제 데이터에 따로따로 적용하라 하는데, 이론적으로는 조금 수상하다 생각할 수 있다.
    • 결국 실제와 생성 데이터에 서로 다른 분포를 가진 Discriminator 함수를 적용하는 꼴이기 때문이다.
    • 하지만 실제로는 그렇게 해야 더 성능이 좋다고 한다..

4. 실습 - MNIST 데이터셋

국민 머신러닝 데이터셋인 MNIST를 넣어 GAN을 돌려보자. Udacity 코스 자료를 가져다 코멘트를 추가했다.

데이터셋 준비
%matplotlib inline

import pickle as pkl
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
from tensorflow.examples.tutorials.mnist import input_data
mnist = input_data.read_data_sets('MNIST_data')
Extracting MNIST_data/train-images-idx3-ubyte.gz
Extracting MNIST_data/train-labels-idx1-ubyte.gz
Extracting MNIST_data/t10k-images-idx3-ubyte.gz
Extracting MNIST_data/t10k-labels-idx1-ubyte.gz
모델 입력값

그래프에 집어넣을 입력값을 만든다. 네트워크가 2개이므로 넣을 입력값도 2개다.

def model_inputs(real_dim, z_dim):
    """
    G와 D에 넣을 입력값인 inputs_real과 inputs_z를 리턴하는 함수
    
    Arguments
    ---------
    real_dim: 실제 인풋의 형태
    z_dim: 랜덤벡터 Z의 형태
    
    Returns
    -------
    inputs_real: D에 넣을 입력값
    inputs_z: G에 넣을 입력값
    """
    inputs_real = tf.placeholder(tf.float32, shape=(None, real_dim), name='input_real')
    inputs_z = tf.placeholder(tf.float32, shape=(None, z_dim), name='input_z')
    
    return inputs_real, inputs_z
Generator

이미지를 생성할 Generator를 구성한다. 입력층 - Leaky Relu를 사용한 은닉층 - tanh를 사용하는 출력층으로 구성한다.

여기서 tf.variable_scope가 사용되는데, 이를 generator라고 지정하면, 그 범위내에 생성되는 모든 변수는 그 이름이 앞에 붙게 된다. discriminator로 마찬가지로 지정하면 나중에 따로따로 변수를 학습시키기에 매우 편리하다.

tf.name_scope도 비슷한 기능을 수행하나, 나중에 다른 입력값만 넣어 네트워크를 재사용해야 하기 때문에 tf.variable_scope를 사용해야 한다. 우리는 Generator를 학습시키면서도, 학습 도중과 끝난 후에 샘플을 채집해야 한다. 또 Discriminator는 가짜와 진짜 이미지간에 변수를 공유해야 한다. 이때 tf.variable_scope가 매우 편리하다.

def generator(z, out_dim, n_units=128, reuse=False,  alpha=0.01):
    ''' 
    Generator 네트워크를 만든다.
    
    Arguments
    ---------
    z : Generator에 넣을 입력값(텐서)
    out_dim : 출력될 결과물의 형태
    n_units : 은닉층 유닛 갯수
    reuse : 재사용 여부
    alpha : Leaky ReLU에 넣을 leak 파라미터

    Returns
    -------
    out: 생성 결과
    '''
    with tf.variable_scope('generator', reuse=reuse):
        # Hidden layer
        h1 = tf.layers.dense(inputs=z, units=n_units, activation=None)
        
        # Leaky ReLU
        ## tf에 별도의 함수가 없어 이렇게 구현해야 한다.
        h1 = tf.maximum(h1 * alpha, h1)
        
        # Logits and tanh output
        logits = tf.layers.dense(inputs=h1, units=out_dim, activation=None)
        out = tf.tanh(logits)
        
        return out
Discriminator

Discriminator는 Generator와 거의 비슷하다. 출력층에 tanh 대신 sigmoid를 쓴다.

def discriminator(x, n_units=128, reuse=False, alpha=0.01):
    ''' 
    Discriminator 네트워크를 만든다.
    
    Arguments
    ---------
    x : Discriminator에 넣을 입력값
    n_units : 은닉층 유닛 갯수
    reuse : 재사용 여부
    alpha : Leaky ReLU에 넣을 leak 파라미터

    Returns
    -------
    out: 분류 결과
    logits: sigmoid 직전 logits
    '''
    with tf.variable_scope('discriminator', reuse=reuse):
        # Hidden layer
        h1 = tf.layers.dense(x, n_units, activation=None)
        
        # Leaky ReLU
        h1 = tf.maximum(alpha * h1, h1)
        
        logits = tf.layers.dense(h1, 1, activation=None)
        out = tf.sigmoid(logits) ## sigmoid를 쓴다.
        
        return out, logits
Hyperparameters

GAN 학습에 필요한 파라미터를 정의한다.

# D에 넣을 입력 데이터의 크기 (MNIST는 28*28인데 784개로 구성된 벡터로 변환해 넣으므로)
input_size = 784
# 랜덤벡터Z의 크기
z_size = 100
# G와 D의 은닉층 유닛 개수
g_hidden_size = 128
d_hidden_size = 128
# Leaky ReLU에 넣을 Leak 파라미터
alpha = 0.01
# 레이블 스무딩 파라미터
smooth = 0.1
Build network

그래프를 만들고, 입력값, Generator 모델, Discriminator 모델을 정의한다.

tf.reset_default_graph()
# 입력값을 정의.
input_real, input_z = model_inputs(input_size, z_size)

# Generator 모델을 구현한다.
g_model = generator(input_z, input_size, n_units=g_hidden_size, alpha=alpha)

# Discriminator 모델을 구현한다.
## 여기서는 진짜와 가짜 2개를 만든다.
## 진짜와 가짜는 모두 같은 네트워크 가중치를 사용해야 하므로, reuse=True를 인자로 넘긴다.
d_model_real, d_logits_real = discriminator(input_real, n_units=d_hidden_size, alpha=alpha)
d_model_fake, d_logits_fake = discriminator(g_model, reuse=True, n_units=d_hidden_size, alpha=alpha)
Discriminator and Generator Losses

loss를 계산해야 하는데 이부분이 조금 어렵다. Discriminator의 loss는 진짜 데이터의 loss와 가짜의 loss의 합계다. 각각의 sigmoid_cross_entropy를 구할 때 labels에 진짜는 1을 가짜는 0을 지정하면 된다.

Generator loss는 가짜 이미지의 로짓인 d_logits_fake를 사용하지만, 앞서 언급했듯, label을 1로 지정해서 구한다.

# Calculate losses
## d_logits_real과 1의 차이가 진짜 데이터의 loss가 된다.
d_loss_real = tf.reduce_mean(
                  tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_real, 
                                                          labels=tf.ones_like(d_logits_real) * (1 - smooth)))

## d_logits_fake와 0의 차이가 가짜 데이터의 loss가 된다.                                                          
d_loss_fake = tf.reduce_mean(
                  tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_fake, 
                                                          labels=tf.zeros_like(d_logits_real)))
                                                          
## d_loss는 진짜와 가짜 loss의 합이다.
d_loss = d_loss_real + d_loss_fake

## g_loss는 가짜 loss와 1(가짜지만, Discriminator와 반대이므로 1)의 차이로 구한다.
g_loss = tf.reduce_mean(
             tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_fake,
                                                     labels=tf.ones_like(d_logits_fake)))
Optimizers

Generater와 Discriminator에 사용하는 변수는 따로따로 업데이트해줘야 한다.

tf.trainable_variables()는 그래프에 선언한 모든 변수를 담은 리스트를 반환한다.

Generator Optimizer에서는 당연히 generator 변수만 필요하다. 앞에서 tf.variable_scope로 이름을 할당해두었으므로, 리스트에서 generator로 시작하는 변수만 따로 추리면 된다. discriminator도 같은 방식으로 추린다.

그리고나서, optimizer의 minimize 함수에 var_list로 변수 리스트를 전달한다. 이로서 optimizer는 전달된 변수만을 학습한다.

# Optimizers
learning_rate = 0.002

# 전체 변수를 가져온 다음, 이름으로 G와 D에 들어갈 변수를 추린다.
t_vars = tf.trainable_variables()
g_vars = [var for var in t_vars if var.name.startswith('generator')]
d_vars = [var for var in t_vars if var.name.startswith('discriminator')]

d_train_opt = tf.train.AdamOptimizer(learning_rate).minimize(d_loss, var_list=d_vars)
g_train_opt = tf.train.AdamOptimizer(learning_rate).minimize(g_loss, var_list=g_vars)
Training
batch_size = 100
epochs = 100
samples = []
losses = []
# Generator 변수만 저장한다.
saver = tf.train.Saver(var_list=g_vars)
with tf.Session() as sess:
    ## 모든 변수를 이니셜라이즈한다.
    sess.run(tf.global_variables_initializer())
    ## epoch을 돌면서...
    for e in range(epochs):
        ## 미니배치를 돌면서
        for ii in range(mnist.train.num_examples//batch_size):
            batch = mnist.train.next_batch(batch_size)
            
            # 실제 이미지를 가져와서 형태와 스케일을 바꾼다 (-> D)
            batch_images = batch[0].reshape((batch_size, 784))
            batch_images = batch_images*2 - 1
            
            # 랜덤벡터 Z를 만든다 (-> G)
            batch_z = np.random.uniform(-1, 1, size=(batch_size, z_size))
            
            # optimizer를 돌린다.
            _ = sess.run(d_train_opt, feed_dict={input_real: batch_images, input_z: batch_z})
            _ = sess.run(g_train_opt, feed_dict={input_z: batch_z})
        
        # 각 epoch의 끝에 loss를 출력한다.
        train_loss_d = sess.run(d_loss, {input_z: batch_z, input_real: batch_images})
        train_loss_g = g_loss.eval({input_z: batch_z})
            
        print("Epoch {}/{}...".format(e+1, epochs),
              "Discriminator Loss: {:.4f}...".format(train_loss_d),
              "Generator Loss: {:.4f}".format(train_loss_g))    
        # 시각화를 위해 저장해둔다.
        losses.append((train_loss_d, train_loss_g))
        
        # 결과 시각화를 위해 샘플을 추출한다.
        sample_z = np.random.uniform(-1, 1, size=(16, z_size))
        gen_samples = sess.run(
                       generator(input_z, input_size, reuse=True),
                       feed_dict={input_z: sample_z})
        samples.append(gen_samples)
        saver.save(sess, './checkpoints_20170701/generator.ckpt')

# 생성한 샘플을 저장한다.
with open('train_samples_20170701.pkl', 'wb') as f:
    pkl.dump(samples, f)
Epoch 1/100... Discriminator Loss: 0.3542... Generator Loss: 4.2156
Epoch 2/100... Discriminator Loss: 0.4807... Generator Loss: 3.5386
Epoch 3/100... Discriminator Loss: 0.4184... Generator Loss: 3.0571
Epoch 4/100... Discriminator Loss: 0.6623... Generator Loss: 2.4477
Epoch 5/100... Discriminator Loss: 0.4933... Generator Loss: 4.4115
Epoch 6/100... Discriminator Loss: 4.0790... Generator Loss: 4.2294
Epoch 7/100... Discriminator Loss: 1.5767... Generator Loss: 2.8047
Epoch 8/100... Discriminator Loss: 4.1314... Generator Loss: 2.4155
Epoch 9/100... Discriminator Loss: 1.5074... Generator Loss: 1.2608
Epoch 10/100... Discriminator Loss: 1.2602... Generator Loss: 1.1861
...
Epoch 90/100... Discriminator Loss: 0.9186... Generator Loss: 2.1974
Epoch 91/100... Discriminator Loss: 1.0408... Generator Loss: 1.8543
Epoch 92/100... Discriminator Loss: 1.1210... Generator Loss: 1.9848
Epoch 93/100... Discriminator Loss: 1.0030... Generator Loss: 1.8941
Epoch 94/100... Discriminator Loss: 1.1500... Generator Loss: 1.7497
Epoch 95/100... Discriminator Loss: 1.0165... Generator Loss: 2.0192
Epoch 96/100... Discriminator Loss: 1.3226... Generator Loss: 1.9656
Epoch 97/100... Discriminator Loss: 1.0169... Generator Loss: 1.3822
Epoch 98/100... Discriminator Loss: 1.0336... Generator Loss: 1.9438
Epoch 99/100... Discriminator Loss: 0.9182... Generator Loss: 1.8705
Epoch 100/100... Discriminator Loss: 0.9737... Generator Loss: 2.0093
Training loss

Generator와 Discriminator의 loss가 어떻게 달라지는지 보자.

fig, ax = plt.subplots()
losses = np.array(losses)
plt.plot(losses.T[0], label='Discriminator')
plt.plot(losses.T[1], label='Generator')
plt.title("Training Losses")
plt.legend()
<matplotlib.legend.Legend at 0x123dde320>

png

생성한 결과물 보기
def view_samples(epoch, samples):
    fig, axes = plt.subplots(figsize=(7,7), nrows=4, ncols=4, sharey=True, sharex=True)
    for ax, img in zip(axes.flatten(), samples[epoch]):
        ax.xaxis.set_visible(False)
        ax.yaxis.set_visible(False)
        im = ax.imshow(img.reshape((28,28)), cmap='Greys_r')
    
    return fig, axes
# Load samples from generator taken while training
with open('train_samples_20170701.pkl', 'rb') as f:
    samples = pkl.load(f)
_ = view_samples(-1, samples)

png

샘플로 16개를 만들어보았다. 1번째와 3번째 줄의 결과는 약간 애매하지만 전반적으로 사람이 알아볼 수 있을 정도다. 총 100개 epoch을 돌렸는데 10개마다 어떻게 생성 퀄리티가 개선되는지 살펴보자.

rows, cols = 10, 6
fig, axes = plt.subplots(figsize=(7,12), nrows=rows, ncols=cols, sharex=True, sharey=True)

for sample, ax_row in zip(samples[::int(len(samples)/rows)], axes):
    for img, ax in zip(sample[::int(len(sample)/cols)], ax_row):
        ax.imshow(img.reshape((28,28)), cmap='Greys_r')
        ax.xaxis.set_visible(False)
        ax.yaxis.set_visible(False)

png

처음에는 전혀 알아볼 수 없는 노이즈가 출력되지만, epoch이 거듭될수록 숫자의 형태가 명확해지는 것을 볼 수 있다.

Sampling from the generator

마지막으로 학습이 끝난 generator를 활용하여 완전히 새로운 결과를 만들어보자.

saver = tf.train.Saver(var_list=g_vars)
with tf.Session() as sess:
    saver.restore(sess, tf.train.latest_checkpoint('checkpoints'))
    sample_z = np.random.uniform(-1, 1, size=(16, z_size))
    gen_samples = sess.run(
                   generator(input_z, input_size, reuse=True),
                   feed_dict={input_z: sample_z})
_ = view_samples(0, [gen_samples])

png

마지막 그림처럼 완전히 알아보기 어려운 것도 존재는 하지만, 전체적으로 꽤 괜찮은 결과가 나왔다. Convolutional network를 쓰지 않고도, epoch을 100번만 돌리고도 이정도 퀄리티가 나온 것은 꽤 고무적인 성과다. 다음에는 DCGAN을 살펴보자.