오늘은 GAN에 대해 배워보자!
4장의 목차는
4.1 소개
4.2 심층 합성곱 GAN(DCGAN)
4.3 와서스테인 GAN-그레디언트 페널티(WGAN-GP)
4.4 조건부 GAN(CGAN)
으로 구성되어 있다.
우선 GAN은 Generative Adversarial Neworks의 약자로, '생성적 적대 신경망'이라고도 한다.
GAN을 소개할 때 가장 많이 드는 예시는 바로 경찰과 위조범인데, 이 책에서도 유사하게 회사의 제품을 위조하는 위조범 vs. 위조 제품을 탐지하는 회사 간 경쟁 구도를 들어 GAN을 설명하고 있다.
GAN은 생성자(generator)과 판별자(discriminator) 간의 싸움인데, 앞서 예시로 들었던 생성자는 회사의 제품을 위조하는 위조범이고 판별자는 위조범이 생상한 제품과 자회사의 제품을 판별해내는 회사이다.
둘의 역할을 간단하게 말하자면!
자 이제 초기 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]로 조정하는 이유가 무엇일까?
다양한 이유가 있지만 가장 큰 이유는 활성화 함수의 일관성을 위해서이다!
입력데이터와 출력 데이터가 같은 범위를 가지면 모델이 더 일관되게 학습할 수 있기 때문!
이제 진위를 가려내는 판별자에 대해 배워보자.
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 로 줄어든다.
Conv2D(1, kernel_size=4, strides=1, padding='valid') 코드에서 공간 크기가 (4-4+1)x(4-4+1)=1x1이 되기 때문!sigmoid 활성화 함수:
결과가 [0,1] 사이로 출력된다. (참 or 거짓 판별위해 단일 확률 값을 출력!)
마지막에 Dense층이 없는 이유:
마지막 conv층을 거쳤을 때 텐서의 크기가 1x1x1이기 때문.
생성자 코드를 설명하기에 앞서 생성자의 입력에 대해 알아보자.
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()
판별자와 생성자를 공부했으니 이제는 모델의 핵심인 훈련과정에 대해 알아보자!
훈련과정은 훈련 세트의 진짜 샘플과 생성자의 출력을 합쳐서 훈련 세트를 만든 뒤 지도학습을 하는 것으로 이루어져있다.
이 지도학습에서 진짜 이미지 레이블=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}
위 그림에서 epoch가 진행됨에 따라 생성자가 이미지를 더 정교하게 추출한다는 것을 알 수 있다.
GAN의 훈련과정에서 중요한 것은 단순히 생성자가 이미지를 추출하는 것이 아니라 원본 데이터에 있는 것 같은 이미지를 추출하는 것인데, 이를 위해서 진짜 이미지와 생성자가 만든 가짜 이미지 사이 거리를 비교해보자.
GAN에서 훈련을 진행할 때 문제가 생기는 경우는 다음과 같다.
2017년에 공개된 WGAN-GP를 다룬 논문은 기존의 손실함수인 이진 크로스 엔트로피가 아닌 와서스테인 손실함수(Wasserstein Loss Function)을 사용해 GAN의 안정도를 높였다.
손실함수 관련 내용이 꽤 복잡해 직접 필기하며 정리해보았다.

필기에 있는 것처럼 와서스테인 손실함수는 기존의 손실함수와 다음과 같은 차이점이 있다.
1.타켓값 변화
2.시그모이드 활성화 함수를 제거 -> log 사용 x -> 결과값의 범위가 무한대!
앞에서 와서스테인 손실함수에 배웠다. 하지만 와서스테인 손실함수의 값은 범위가 너무 커서 비평자(critic; discriminator와 똑같음)에 제약을 걸어야 한다. 이를 위해 비평자는 1-립시츠 연속 함수(1-Lipschitz continuous func)이여야 한다.
립시츠 함수는 임의의 두 지점의 기울기가 어떤 상숫값 이상으로 증가하지 않는 함수이다.
상수가 1일 때 "1-립시츠 함수"라고 부른다.

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

WGAN 논문에서는 비평자의 가중치를 [-0.01, 0.01] 안에 놓이도록 훈련 배치를 한 다음->가중치 클리핑(weight clipping)을 사용해 립시츠 제약을 부과했다.
근데 이렇게 립시츠 제약을 부과하면 비평자의 성능이 약해진다.
그 이유는 립시츠 제약을 통해 비평자의 함수가 너무 급격하게 변동하는 것을 제한하면, 비평자가 매우 세밀하거나 극단적인 평가를 내리는 능력이 줄어들기 때문이다.
결과적으로 생성자가 실제 데이터 분포를 정확하게 학습하는 데 필요한 충분하고 구체적인 피드백을 받지 못할 수도 있다.
이를 보완하기 위해 WGAN-gradient penalty을 비판자에 손실함수에 포함시켰다.
WGAN-gradient penalty란 gradient norm이 1에서 벗어날 경우 모델에 불이익을 주는 것
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
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}
<출력 결과>

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

(출처:https://kr.mathworks.com/help/deeplearning/ug/train-conditional-generative-adversarial-network.html)
이 그림은 CGAN의 구조를 보여준다.
생성자에서는 원핫인코딩된 벡터를 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에 추가
자 그럼 본격적으로 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}

윗쪽 출력: 레이블이 0일 때로, 금발이 아닌 특성을 의미
아랫쪽 출력: 레이블이 1일 때로, 금발인 특성을 의미
위 출력 결과를 통해 GAN에 기존의 랜덤한 latent space vector 에 레이블 벡터를 추가한 CGAN을 통해 특성이 분리되도록 잠재공간의 포인트를 구성할 수 있다는 것을 알 수 있다.