생성 AI - Ch4. GAN(생성적 적대 신경망)

­문재원·2024년 5월 15일

genAI

목록 보기
3/5

오늘은 GAN에 대해 배워보자!

4장의 목차는

4.1 소개
4.2 심층 합성곱 GAN(DCGAN)
4.3 와서스테인 GAN-그레디언트 페널티(WGAN-GP)
4.4 조건부 GAN(CGAN)

으로 구성되어 있다.

4.1 소개

우선 GAN은 Generative Adversarial Neworks의 약자로, '생성적 적대 신경망'이라고도 한다.

GAN을 소개할 때 가장 많이 드는 예시는 바로 경찰과 위조범인데, 이 책에서도 유사하게 회사의 제품을 위조하는 위조범 vs. 위조 제품을 탐지하는 회사 간 경쟁 구도를 들어 GAN을 설명하고 있다.

GAN생성자(generator)판별자(discriminator) 간의 싸움인데, 앞서 예시로 들었던 생성자는 회사의 제품을 위조하는 위조범이고 판별자는 위조범이 생상한 제품과 자회사의 제품을 판별해내는 회사이다.

둘의 역할을 간단하게 말하자면!

  • 생성자는 잡읍에서 원래 데이터셋에서 샘플링한 것처럼 보이는 샘플을 반환
  • 판별자는 샘플이 샘플의 진위를 식별

4.2 심층 합성곱 GAN(DCGAN)

4.2.1 레고 블록 데이터셋

자 이제 초기 GAN 논문에서 다뤘던 DCGAN으로 벽돌 사진을 생성해보자!

우선 우리는 캐글의 레고 블록 이미지 데이터셋을 이용할 것이다.
코드를 그대로 복사해서 사용하면 내 출력과 같은 결과를 얻을 수 있다.

#필요한 라이브러리를 import
import numpy as np
import matplotlib.pyplot as plt

import tensorflow as tf
from tensorflow.keras import (
    layers,
    models,
    callbacks,
    losses,
    utils,
    metrics,
    optimizers,
)

from notebooks.utils import display, sample_batch

#캐글에서 레고 데이터셋 다운로드
 from google.colab import files
 files.upload()

 !mkdir ~/.kaggle
 !cp kaggle.json ~/.kaggle/
 !chmod 600 ~/.kaggle/kaggle.json
 
!kaggle datasets download -d joosthazelzet/lego-brick-images
!unzip -q lego-brick-images.zip

!mkdir output

#다운받은 이미지 파일을 텐서플로 데이터로 만들기

train_data = utils.image_dataset_from_directory(
    "./dataset/.",  //파일이 dataset 폴더 아래에 있다는 의미!
    labels=None,
    color_mode="grayscale",
    image_size=(64, 64),
    batch_size=128,
    shuffle=True,
    seed=42,
    interpolation="bilinear",
)

#이미지 전처리 -  이미지 정규화 및 크기 변경

def preprocess(img):

    img = (tf.cast(img, "float32") - 127.5) / 127.5
    return img

train = train_data.map(lambda x: preprocess(x))

이미지 전처리 단계에 대한 설명을 해보자!

우선 우리의 목표는 생성자 마지막 층(layer)에서 tanh 활성화 함수를 사용하는 것이 목표이다. 이 tanh 활성화 함수를 사용하기 위해 이미지 스케일을 [-1, 1]범위로 조정하는 것이다!

아래는 tanh 활성화 함수에 대한 간단한 설명이다.

tanh활성화 함수란?

딥러닝에서 주로 쓰는 활성화함수의 모음이다. 이 중 tanh 함수는 출력이 [-1,1] 사이이며, 시그모이드 함수보다 더 강한 그레디언트(gradient)를 제공한다.
그렇다면 tanh함수를 사용하기 위해 데이터셋의 범위를 [-1,1]로 조정하는 이유가 무엇일까?
다양한 이유가 있지만 가장 큰 이유는 활성화 함수의 일관성을 위해서이다!
입력데이터와 출력 데이터가 같은 범위를 가지면 모델이 더 일관되게 학습할 수 있기 때문!

4.2.2 판별자

이제 진위를 가려내는 판별자에 대해 배워보자.

discriminator_input = layers.Input(shape=(IMAGE_SIZE, IMAGE_SIZE, CHANNELS))
x = layers.Conv2D(64, kernel_size=4, strides=2, padding="same", use_bias=False)(
    discriminator_input
)
x = layers.LeakyReLU(0.2)(x)
x = layers.Dropout(0.3)(x)
x = layers.Conv2D(
    128, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)
x = layers.Dropout(0.3)(x)
x = layers.Conv2D(
    256, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)
x = layers.Dropout(0.3)(x)
x = layers.Conv2D(
    512, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)
x = layers.Dropout(0.3)(x)
x = layers.Conv2D(
    1,
    kernel_size=4,
    strides=1,
    padding="valid",
    use_bias=False,
    activation="sigmoid",
)(x)
discriminator_output = layers.Flatten()(x)

discriminator = models.Model(discriminator_input, discriminator_output)
discriminator.summary()

판별자에서는

  • convolutional layer:
    합성곱층 5개를 사용하고 각 합성곱층의 strides=2이기 때문에 각 합성곱층을 지날 때마다 텐서의 크기가 64-> 32 -> 16 -> 8 -> 4 -> 1 로 줄어든다.

    • 마지막 conv층에서 텐서의 크기가 1x1x1로 줄어드는 이유는
      Conv2D(1, kernel_size=4, strides=1, padding='valid') 코드에서 공간 크기가 (4-4+1)x(4-4+1)=1x1이 되기 때문!
  • sigmoid 활성화 함수:
    결과가 [0,1] 사이로 출력된다. (참 or 거짓 판별위해 단일 확률 값을 출력!)

  • 마지막에 Dense층이 없는 이유:
    마지막 conv층을 거쳤을 때 텐서의 크기가 1x1x1이기 때문.

4.2.3 생성자

생성자 코드를 설명하기에 앞서 생성자의 입력에 대해 알아보자.

DCGAN의 Generator에서 입력으로 사용되는 벡터(길이가 100)은 latent space에서 샘플링된 벡터이다.
이 벡터는 랜덤 노이즈 벡터로, 생성기가 이 벡터를 입력으로 받아들인 뒤 가짜 이미지를 생성한다.

아래는 latent space vector 생성 예제에 대해 알아보겠다.

import numpy as np

# 잠재 공간 벡터의 차원
latent_dim = 100

# 잠재 공간 벡터 생성 (예: 배치 크기 16)
batch_size = 16
random_latent_vectors = np.random.normal(0, 1, (batch_size, latent_dim))

자 그럼 이제 본격적인 생성자 코드에 대해 알아보겠다.

#latent space vector의 입력
generator_input = layers.Input(shape=(100,))

# 잠재 공간 벡터를 1x1x100 크기로 변환 //Z_DIM=100
x = layers.Reshape((1, 1, Z_DIM))(generator_input)

#첫번째 합성곱층: 1X1 ->4X4
x = layers.Conv2DTranspose(
    512, kernel_size=4, strides=1, padding="valid", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)

#2번째 합성곱층: 4X4 -> 8X8
x = layers.Conv2DTranspose(
    256, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)

#3번째 합성곱층: 8X8 -> 16X16
x = layers.Conv2DTranspose(
    128, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)

#4번째 합성곱층: 16X16 -> 32X32
x = layers.Conv2DTranspose(
    64, kernel_size=4, strides=2, padding="same", use_bias=False
)(x)
x = layers.BatchNormalization(momentum=0.9)(x)
x = layers.LeakyReLU(0.2)(x)

#마지막 합성곱층: 32X32 -> 64X64
generator_output = layers.Conv2DTranspose(
    1,
    kernel_size=4,
    strides=2,
    padding="same",
    use_bias=False,
    activation="tanh",
)(x)
generator = models.Model(generator_input, generator_output)
generator.summary()

4.2.4 DCGAN 훈련

판별자와 생성자를 공부했으니 이제는 모델의 핵심인 훈련과정에 대해 알아보자!

훈련과정은 훈련 세트의 진짜 샘플과 생성자의 출력을 합쳐서 훈련 세트를 만든 뒤 지도학습을 하는 것으로 이루어져있다.

이 지도학습에서 진짜 이미지 레이블=1, 가짜 이미지 레이블=0으로 학습을 진행하며 손실함수로는 이진 크로스 엔트로피를 사용한다.

다음은 생성함수의 훈련과정에 대해 알아보자!

우선 생성된 이미지에 점수를 부여한 뒤 높은 점수를 낸 이미지를 최적화한다는 것이 핵심이다.


[출처: https://www.researchgate.net/figure/The-architecture-of-vanilla-GANs_fig1_340458845]

이 그림을 통해 알 수 있듯이 생성자가 noise에서 생성한 이미지를 판별자가 진위(real or fake)인지 가려내는 방식으로 훈련이 진행되며 이 과정에서 생성자와 판별자의 가중치를 업데이트하는 것이 훈련의 핵심이다.

class DCGAN(models.Model):
    def __init__(self, discriminator, generator, latent_dim):
        super(DCGAN, self).__init__()
        self.discriminator = discriminator
        self.generator = generator
        self.latent_dim = latent_dim

    def compile(self, d_optimizer, g_optimizer):
        super(DCGAN, self).compile()
        self.loss_fn = losses.BinaryCrossentropy()
        self.d_optimizer = d_optimizer
        self.g_optimizer = g_optimizer
        self.d_loss_metric = metrics.Mean(name="d_loss")
        self.d_real_acc_metric = metrics.BinaryAccuracy(name="d_real_acc")
        self.d_fake_acc_metric = metrics.BinaryAccuracy(name="d_fake_acc")
        self.d_acc_metric = metrics.BinaryAccuracy(name="d_acc")
        self.g_loss_metric = metrics.Mean(name="g_loss")
        self.g_acc_metric = metrics.BinaryAccuracy(name="g_acc")

    @property
    def metrics(self):
        return [
            self.d_loss_metric,
            self.d_real_acc_metric,
            self.d_fake_acc_metric,
            self.d_acc_metric,
            self.g_loss_metric,
            self.g_acc_metric,
        ]

    def train_step(self, real_images):
        # 잠재 공간에서 랜덤 포인트 샘플링
        batch_size = tf.shape(real_images)[0]
        random_latent_vectors = tf.random.normal(
            shape=(batch_size, self.latent_dim)
        )

        # 가짜 이미지로 판별자 훈련하기
        with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape:
            generated_images = self.generator(
                random_latent_vectors, training=True
            )
            real_predictions = self.discriminator(real_images, training=True)
            fake_predictions = self.discriminator(
                generated_images, training=True
            )

            real_labels = tf.ones_like(real_predictions)
            real_noisy_labels = real_labels + NOISE_PARAM * tf.random.uniform(
                tf.shape(real_predictions)
            )
            fake_labels = tf.zeros_like(fake_predictions)
            fake_noisy_labels = fake_labels - NOISE_PARAM * tf.random.uniform(
                tf.shape(fake_predictions)
            )

            d_real_loss = self.loss_fn(real_noisy_labels, real_predictions)
            d_fake_loss = self.loss_fn(fake_noisy_labels, fake_predictions)
            d_loss = (d_real_loss + d_fake_loss) / 2.0

            g_loss = self.loss_fn(real_labels, fake_predictions)

        gradients_of_discriminator = disc_tape.gradient(
            d_loss, self.discriminator.trainable_variables
        )
        gradients_of_generator = gen_tape.gradient(
            g_loss, self.generator.trainable_variables
        )

        self.d_optimizer.apply_gradients(
            zip(gradients_of_discriminator, discriminator.trainable_variables)
        )
        self.g_optimizer.apply_gradients(
            zip(gradients_of_generator, generator.trainable_variables)
        )

        # 메트릭 업데이트
        self.d_loss_metric.update_state(d_loss)
        self.d_real_acc_metric.update_state(real_labels, real_predictions)
        self.d_fake_acc_metric.update_state(fake_labels, fake_predictions)
        self.d_acc_metric.update_state(
            [real_labels, fake_labels], [real_predictions, fake_predictions]
        )
        self.g_loss_metric.update_state(g_loss)
        self.g_acc_metric.update_state(real_labels, fake_predictions)

        return {m.name: m.result() for m in self.metrics}
       

4.2.5 분석

위 그림에서 epoch가 진행됨에 따라 생성자가 이미지를 더 정교하게 추출한다는 것을 알 수 있다.

GAN의 훈련과정에서 중요한 것은 단순히 생성자가 이미지를 추출하는 것이 아니라 원본 데이터에 있는 것 같은 이미지를 추출하는 것인데, 이를 위해서 진짜 이미지와 생성자가 만든 가짜 이미지 사이 거리를 비교해보자.

4.2.6 GAN 훈련의 팁과 트릭

GAN에서 훈련을 진행할 때 문제가 생기는 경우는 다음과 같다.

  • 핀별자가 생성자보다 훨씬 뛰어난 경우
  • 생성자가 판별자보다 훨씬 뛰어난 경우
  • 생성자의 손실함수의 무쓸모
  • 많은 하이퍼파라미터

4.3 WGAN-GP(와서 스테인 GAN-그레디언트 패널티)

2017년에 공개된 WGAN-GP를 다룬 논문은 기존의 손실함수인 이진 크로스 엔트로피가 아닌 와서스테인 손실함수(Wasserstein Loss Function)을 사용해 GAN의 안정도를 높였다.

4.3.1 Wasserstein Loss Function

손실함수 관련 내용이 꽤 복잡해 직접 필기하며 정리해보았다.

필기에 있는 것처럼 와서스테인 손실함수는 기존의 손실함수와 다음과 같은 차이점이 있다.
1.타켓값 변화
2.시그모이드 활성화 함수를 제거 -> log 사용 x -> 결과값의 범위가 무한대!

4.3.2 립시츠 제약

앞에서 와서스테인 손실함수에 배웠다. 하지만 와서스테인 손실함수의 값은 범위가 너무 커서 비평자(critic; discriminator와 똑같음)에 제약을 걸어야 한다. 이를 위해 비평자는 1-립시츠 연속 함수(1-Lipschitz continuous func)이여야 한다.

립시츠 함수는 임의의 두 지점의 기울기가 어떤 상숫값 이상으로 증가하지 않는 함수이다.
상수가 1일 때 "1-립시츠 함수"라고 부른다.


위 함수는 1-립시츠 함수이며 더 설명을 해보자면

  • D: 비평자를 의미
  • |D(x1)-D(x2)|: 비평자 예측 간의 절댓값 차이
  • |x1-x2|: 두 이미지 픽셀의 평균적인 절댓값 차이
    -> '위 수식을 아래 수식으로 나눈 값<=1'이라는 것은 비평자의 예측을 제한한다는 의미로, 결과적으로
    위 그림은 직선이 초록색 부분에만 속한다는 것을 보여주며, 기울기가 [-1,1]에 속한다는 것을 알 수 있다.

4.3.3 립시츠 제약 부과하기

WGAN 논문에서는 비평자의 가중치를 [-0.01, 0.01] 안에 놓이도록 훈련 배치를 한 다음->가중치 클리핑(weight clipping)을 사용해 립시츠 제약을 부과했다.

근데 이렇게 립시츠 제약을 부과하면 비평자의 성능이 약해진다.
그 이유는 립시츠 제약을 통해 비평자의 함수가 너무 급격하게 변동하는 것을 제한하면, 비평자가 매우 세밀하거나 극단적인 평가를 내리는 능력이 줄어들기 때문이다.
결과적으로 생성자가 실제 데이터 분포를 정확하게 학습하는 데 필요한 충분하고 구체적인 피드백을 받지 못할 수도 있다.

이를 보완하기 위해 WGAN-gradient penalty을 비판자에 손실함수에 포함시켰다.

WGAN-gradient penalty란 gradient norm이 1에서 벗어날 경우 모델에 불이익을 주는 것

4.3.4 gradient penalty loss

gradient penalty loss는 (입력 이미지에 대한 그레디언트 노름-1)^2로 학습과정에서 모델이 이 페널티를 최소화하는 가중치를 찾기 때문에 립시츠 제약을 따른다는 점을 알 수 있다.

대신 모든 이미지에 적용하는 것이 아니라 일부 이미지에 선택적으로 적용하며, 이때 진짜 이미지와 가짜 이미지 쌍 사이를 interpolation한 이미지를 이용한다.

아래 코드는 gradient penalty loss 코드이다.


    def gradient_penalty(self, batch_size, real_images, fake_images):
        alpha = tf.random.normal([batch_size, 1, 1, 1], 0.0, 1.0) //1
        diff = fake_images - real_images
        interpolated = real_images + alpha * diff //2

        with tf.GradientTape() as gp_tape:
            gp_tape.watch(interpolated)
            pred = self.critic(interpolated, training=True) //3

        grads = gp_tape.gradient(pred, [interpolated])[0] //4
        norm = tf.sqrt(tf.reduce_sum(tf.square(grads), axis=[1, 2, 3])) //5
        gp = tf.reduce_mean((norm - 1.0) ** 2) //6
        return gp
 
  1. 배치에 있는 이미지마다 0~1 사이 랜덤한 숫자 생성해 벡터 alpha에 저장
  2. interpolation 이미지 계산
  3. 비평자가 interpolation 이미지에서 예측값 계산
  4. interpolation 이미지에 대해 예측의 그레디언트 계산
  5. 벡터의 L2 norm 계산
  6. L2 norm 과 1 사이의 평균 제곱 거리를 반환.

4.3.5 WGAN-GP 훈련

    def train_step(self, real_images):
        batch_size = tf.shape(real_images)[0]

        for i in range(self.critic_steps):
            random_latent_vectors = tf.random.normal(
                shape=(batch_size, self.latent_dim)
            )

            with tf.GradientTape() as tape:
                fake_images = self.generator(
                    random_latent_vectors, training=True
                )
                fake_predictions = self.critic(fake_images, training=True)
                real_predictions = self.critic(real_images, training=True)

                c_wass_loss = tf.reduce_mean(fake_predictions) - tf.reduce_mean(
                    real_predictions
                )
                c_gp = self.gradient_penalty(
                    batch_size, real_images, fake_images
                )
                c_loss = c_wass_loss + c_gp * self.gp_weight

            c_gradient = tape.gradient(c_loss, self.critic.trainable_variables)
            self.c_optimizer.apply_gradients(
                zip(c_gradient, self.critic.trainable_variables)
            )

        random_latent_vectors = tf.random.normal(
            shape=(batch_size, self.latent_dim)
        )
        with tf.GradientTape() as tape:
            fake_images = self.generator(random_latent_vectors, training=True)
            fake_predictions = self.critic(fake_images, training=True)
            g_loss = -tf.reduce_mean(fake_predictions)

        gen_gradient = tape.gradient(g_loss, self.generator.trainable_variables)
        self.g_optimizer.apply_gradients(
            zip(gen_gradient, self.generator.trainable_variables)
        )

        self.c_loss_metric.update_state(c_loss)
        self.c_wass_loss_metric.update_state(c_wass_loss)
        self.c_gp_metric.update_state(c_gp)
        self.g_loss_metric.update_state(g_loss)

        return {m.name: m.result() for m in self.metrics}

<출력 결과>

4.4 CGAN(Conditional GAN)

CGAN에서는 생성하려는 이미지의 유형(남성/여성의 얼굴, 금발 등)을 제어할 수 있다.

(출처:https://kr.mathworks.com/help/deeplearning/ug/train-conditional-generative-adversarial-network.html)

이 그림은 CGAN의 구조를 보여준다.

  • 생성자: noise + 원핫 인코딩된 레이블
  • 판별자: real/generated image + 원핫 인코딩된 레이블 채널

생성자에서는 원핫인코딩된 벡터를 latent space sample에 추가하고
판별자에서는 레이블 정보를 기존의 RGB이미지에 추가 채널로 추가한다.

다음은 CGAN 입력층 코드이다.

critic_input = layers.Input(shape=(32, 32, 3))
label_input = layers.Input(shape=(32, 32, 2)) #레이블이 2개라서 channel 수가 2개!
x = layers.Concatenate(axis=-1)([critic_input, label_input])

...

generator_input = layers.Input(shape=(32,))
label_input = layers.Input(shape=(2,))
x = layers.Concatenate(axis=-1)([generator_input, label_input])
x = layers.Reshape((1, 1, 34))(x) #원한 인코딩 벡터를 기존 input에 추가

4.4.2 CGAN 훈련

자 그럼 본격적으로 CGAN을 훈련해보자.

   def train_step(self, data):
        real_images, one_hot_labels = data #1. 입력데이터에서 이미지와 레이블 분류

        image_one_hot_labels = one_hot_labels[:, None, None, :] #2. 원핫 인코딩된 벡터를 입력이미지의 크기와 같은 원핫 인코딩된 이미지로 확장
        image_one_hot_labels = tf.repeat(
            image_one_hot_labels, repeats=IMAGE_SIZE, axis=1
        )
        image_one_hot_labels = tf.repeat(
            image_one_hot_labels, repeats=IMAGE_SIZE, axis=2
        )

        batch_size = tf.shape(real_images)[0]

        for i in range(self.critic_steps):
            random_latent_vectors = tf.random.normal(
                shape=(batch_size, self.latent_dim)
            )

            with tf.GradientTape() as tape:
                fake_images = self.generator(   #3. 생성자에게 두개의 입력으로 구성된 리스트 주입
                    [random_latent_vectors, one_hot_labels], training=True
                )

                fake_predictions = self.critic( #4. 비평자에게  개의 입력으로 구성된 리스트 주입
                    [fake_images, image_one_hot_labels], training=True
                )
                real_predictions = self.critic(
                    [real_images, image_one_hot_labels], training=True
                )

                c_wass_loss = tf.reduce_mean(fake_predictions) - tf.reduce_mean(
                    real_predictions
                )
                c_gp = self.gradient_penalty( #5. gradient penalty 함수도 비평자를 호출할 때 원핫 인코딩된 레이블 채널이 필요
                    batch_size, real_images, fake_images, image_one_hot_labels
                )
                c_loss = c_wass_loss + c_gp * self.gp_weight

            c_gradient = tape.gradient(c_loss, self.critic.trainable_variables)
            self.c_optimizer.apply_gradients(
                zip(c_gradient, self.critic.trainable_variables)
            )

        random_latent_vectors = tf.random.normal(
            shape=(batch_size, self.latent_dim)
        )

        with tf.GradientTape() as tape:   #6. 생성자의 gradient  계산
            fake_images = self.generator(   #7. 비평자에서 추가로 대입했던 원핫인코딩 레이블은 생성자에도 적용
                [random_latent_vectors, one_hot_labels], training=True
            )
            fake_predictions = self.critic(
                [fake_images, image_one_hot_labels], training=True
            )
            g_loss = -tf.reduce_mean(fake_predictions)

        gen_gradient = tape.gradient(g_loss, self.generator.trainable_variables)
        self.g_optimizer.apply_gradients(
            zip(gen_gradient, self.generator.trainable_variables)
        )

        self.c_loss_metric.update_state(c_loss)
        self.c_wass_loss_metric.update_state(c_wass_loss)
        self.c_gp_metric.update_state(c_gp)
        self.g_loss_metric.update_state(g_loss)

        return {m.name: m.result() for m in self.metrics}

4.4.3 CGAN 분석


윗쪽 출력: 레이블이 0일 때로, 금발이 아닌 특성을 의미
아랫쪽 출력: 레이블이 1일 때로, 금발인 특성을 의미

위 출력 결과를 통해 GAN에 기존의 랜덤한 latent space vector 에 레이블 벡터를 추가한 CGAN을 통해 특성이 분리되도록 잠재공간의 포인트를 구성할 수 있다는 것을 알 수 있다.

profile
얼렁뚱땅 요리조리

0개의 댓글