신기한 딥러닝의 세계

본 문서는 [케라스 창시자에게 배우는 딥러닝] 책을 기반으로 하고 있으며, subinium(본인)이 정리하고 추가한 내용입니다. 생략된 부분과 추가된 부분이 있으니 추가/수정하면 좋을 것 같은 부분은 댓글로 이야기해주시면 감사하겠습니다.

  • 이번 장에서는 다양한 사용이 있다는 정도만 알기 위해, 코드는 따로 공부하지 않고, 구현만 해보았습니다.
  • 내용 자체가 어렵다보니 글을 읽고 이해해서 제 스타일로 서술하지 못하고, 책과 동일하게 쓴 부분이 많습니다.
  • 조만간 이번 장의 절들을 하나씩 테마로 잡고 글을 써야겠습니다.

AI는 더 이상 소극적 작업이 아닌 확장된 지능(augmented intelligence) 의 역할을 하고 있습니다. 그림, 음악 등 다양한 분야에서 사람의 능력을 증가시킬 수 있는 것입니다. 딥러닝은 통계적 구조를 학습하며 이미지, 음악, 글 등의 잠재 공간(latent space) 를 학습합니다.

이 장에서는 예술 창작에 딥러닝이 사용되는 예시를 알아보도록 합니다. 기술과 예술의 조화를 보도록 합시다.

8.1 LSTM으로 텍스트 생성하기

이 절에서는 순환 신경망을 이용한 시퀀스 데이터 생성을 목표로 합니다. 책의 예시는 텍스트를 다루지만, 음악은 음표를 그림은 붓질을 적용할 수 있습니다. 예술만이 아닌 음성 합성과 챗봇의 대화 기능에도 적용하고 있습니다.

8.1.1 생성 RNN의 간단한 역사

생략

8.1.2 시퀀스 데이터를 어떻게 생성할까?

딥러닝에서 시퀀스 데이터를 생성하는 일반적인 방법은 이전 토큰을 입력으로 사용해서 시퀀스의 다음 1개 또는 몇 개의 토큰을 예측하는 것입니다.(RNN이나 컨브넷으로) 예를 들어 “the cat is on ma”란 입력이 주어지면 다음 글자인 타깃 “t”를 예측하도록 네트워크를 훈련합니다. 텍스트를 다룰 때 토큰은 보통 단어 또는 글자입니다. 이전 토큰들이 주어졌을 때 다음 토큰의 확률을 모델링할 수 있는 네트워크 를 언어 모델(language model) 이라고 부릅니다. 언어의 통계적 구조인 잠재 공간을 탐색합니다.

언어 모델을 훈련하고 나면 새로운 시퀀스를 생성할 수 있습니다. 초기 텍스트 문자열을 주입하고(조건 데이터(conditioning data)) 새로운 글자난 단어를 생성합니다. 생성된 출력은 다시 입력 데이터로 추가됩니다. 반복을 통해 모델이 훈련한 데이터 구조가 반영된 임의의 길이를 가진 시퀀스를 생성할 수 있습니다.

이 절에서는 LSTM 층을 사용합니다. 텍스트 말뭉치에서 N개의 글자로 이루어진 문자열을 추출하여 주입하고 N+1 번째 글자를 예측하도록 훈련합니다. 모델의 출력은 가능한 모든 글자에 해당하는 소프트맥스 값입니다. 이 LSTM을 글자 수준의 신경망 언어 모델(character-level neural language model) 이라고 부릅니다.

8.1.3 샘플링 전략의 중요성

텍스트를 생성할 때 다음 글자를 선택하는 방법은 매우 중요합니다. Naive한 방법으로 가장 높은 확률을 가진 글자를 선택하는 탐욕적 샘플링(greedy sampling) 이 있습니다. 이 방법은 반복적이고 예상 가능한 문자열을 만들기 때문에 논리적인 언어처럼 보이지 않습니다.

여기서 더 나아가 무작위성을 추가하여 확률적 샘플링(stochastic sampling) 을 할 수 있습니다. 소프트맥스 결과값에서 가중치에 따라 랜덤으로 선택하는 것입니다. 이와 같이하면 글이 새로운 방향으로 흘러갈 확률도 많습니다. 하지만 여기서는 무작위성의 양을 조절할 수 없다는 단점이 있습니다.

샘플링 과정에서 확률적인 양을 조절하기 위해 소프트맥스 온도(softmax temperature) 라는 파라미터를 사용합니다. 이 파라미터는 샘플링에 사용되는 확률 분포의 엔트로피를 나타냅니다. 얼마나 놀라운 또는 예상되는 글자를 선택할지 결정합니다.

temperature 값이 주어지면 다음과 같이 가중치를 적용하여 원본 확률 분포에서 새로운 확률 분포를 계산합니다. 높은 온도는 엔트로피가 높은 샘플링 분포를 만들어 더 생소한 데이터를 생성하고, 낮은 온도는 예상할 수 있는 데이터를 생성합니다.

8.1.4 글자 수준의 LSTM 텍스트 생성 모델 구현

이 절에서는 위 아이디어를 케라스로 구현해봅니다. 언어 모델을 학습하기 위해 많은 텍스트 데이터가 필요합니다. 위키피디아반지의 제왕 같은 아주 큰 텍스트 파일이나 묶음을 이용할 수 있습니다. 책에서는 19세기 후반의 독일 철학자 니체의 글(영문)을 이용합니다. 학습할 언어 모델은 일반적인 영어 모델이 아니라 니체의 문체와 특정 주체를 따르는 모델일 것입니다.

데이터 전처리

먼저 말뭉치를 내려받아 소문자로 바꿉니다.

# 코드 8-2 원본 텍스트 파일을 내려받아 파싱하기
import keras
import numpy as np

path = keras.utils.get_file('nietzsche.txt', origin='https://s3.amazonaws.com/text-datasets/nietzsche.txt')
text = open(path).read().lower()
print('length  of corpus : ', len(text))
Using TensorFlow backend.
Downloading data from https://s3.amazonaws.com/text-datasets/nietzsche.txt
606208/600901 [==============================] - 0s 0us/step
length  of corpus :  600893

그 다음 maxlen 길이를 가진 시퀀스르 중복하여 추출합니다. 추출된 시퀀스를 원-핫 인코딩으로 변환하고 (sequences, maxlen, unique_characters)인 3D 넘파이 배열 x로 합칩니다. 동시에 훈련 샘플에 상응하는 타깃을 담은 배열 y를 준비합니다. 타깃은 추출된 시퀀스 다음에 오는 원-핫 인코딩된 글자입니다.

# 코드 8-3 글자 시퀀스 벡터화하기

maxlen = 60
step = 3

sentences = []
next_chars = []

for i in range(0, len(text) - maxlen, step):
  sentences.append(text[i:i+maxlen])
  next_chars.append(text[i+maxlen])

print('Number of sequences : ', len(sentences))

chars = sorted(list(set(text)))
print('고유한 글자: ', len(chars))
char_indices = dict((char, chars.index(char)) for char in chars)

print('Vectorize...')

x = np.zeros((len(sentences),maxlen, len(chars)), dtype=np.bool)
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
for i, sentence in enumerate(sentences):
  for t, char in enumerate(sentence):
    x[i, t, char_indices[char]] = 1
  y[i, char_indices[next_chars[i]]] = 1
Number of sequences :  200278
고유한 글자:  57
Vectorize...

네트워크 구성

이 네트워크는 하나의 LSTM 층과 그 뒤에 Dense 분류기가 뒤따릅니다. 분류기는 가능한 모든 글자에 대한 소프트맥스 출력을 만듭니다. 순환 신경망이 시퀀스 데이터를 생성하는 유일한 방법은 아닙니다. 최근에는 1D 컨브넷도 이런 작업에 아주 잘 들어맞는다는 것이 밝혀졌습니다. (6장)

# 코드 8-4 다음 글자를 예측하기 위한 단일 LSTM 모델
from keras import layers

model = keras.models.Sequential()
model.add(layers.LSTM(128, input_shape=(maxlen, len(chars))))
model.add(layers.Dense(len(chars), activation='softmax'))

타깃이 원-핫 인코딩되어 있기 때문에 모델을 훈련하기 위해 categorical_crossentropy 손실을 사용합니다.

# 코드 8-5 모델 컴파일 설정하기
optimizer = keras.optimizers.RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)

언어 모델 훈련과 샘플링

훈련된 모델과 시드(seed)로 쓰일 간단한 텍스트가 주어지면 다음과 같이 반복하여 새로운 텍스트를 생성할 수 있습니다.

  1. 지금까지 생성된 텍스트를 주입하여 모델에서 다음 글자에 대한 확률 분포를 뽑습니다.
  2. 특정 온도로 이 확률 분포의 가중치를 조정합니다.
  3. 가중치가 조정된 분포에서 무작위로 새로운 글자를 샘플링합니다.
  4. 새로운 글자를 생성된 텍스트의 끝에 추가합니다.

다음 코드는 모델에서 나온 원본 확률 분포의 가중치를 조정하고 새로운 글자의 인덱스를 추출합니다.

# 코드 8-6 모델의 예측이 주어졌을 떄 새로운 글자를 샘플링하는 함수
def sample(preds, temperature=1.0):
  preds = np.asarray(preds).astype('float64')
  preds = np.log(preds) / temperature
  exp_preds = np.exp(preds)
  preds = exp_preds / np.sum(exp_preds)
  probas = np.random.multinomial(1, preds, 1)
  return np.argmax(probas)

마지막으로 반복적으로 훈련하고 텍스트를 생성하는 반복문입니다. 에포크마다 학습이 끝난 후 여러 가지 온도를 사용하여 텍스트를 사용합니다. 이렇게 하면 모델이 수렴하면서 생성된 텍스트를 어떻게 진화하는지 볼 수 있습니다. 온도가 샘플링 전략에 미치는 영향도 보여줍니다.

# 코드 8-7 텍스트 생성 루프
import random
import sys

random.seed(42)
start_index = random.randint(0, len(text) - maxlen - 1)

# 60 에포크 동안 모델을 훈련합니다
for epoch in range(1, 60):
    print('에포크', epoch)
    # 데이터에서 한 번만 반복해서 모델을 학습합니다
    model.fit(x, y, batch_size=128, epochs=1)

    # 무작위로 시드 텍스트를 선택합니다
    seed_text = text[start_index: start_index + maxlen]
    print('--- 시드 텍스트: "' + seed_text + '"')

    # 여러가지 샘플링 온도를 시도합니다
    for temperature in [0.2, 0.5, 1.0, 1.2]:
        print('------ 온도:', temperature)
        generated_text = seed_text
        sys.stdout.write(generated_text)

        # 시드 텍스트에서 시작해서 400개의 글자를 생성합니다
        for i in range(400):
            # 지금까지 생성된 글자를 원-핫 인코딩으로 바꿉니다
            sampled = np.zeros((1, maxlen, len(chars)))
            for t, char in enumerate(generated_text):
                sampled[0, t, char_indices[char]] = 1.

            # 다음 글자를 샘플링합니다
            preds = model.predict(sampled, verbose=0)[0]
            next_index = sample(preds, temperature)
            next_char = chars[next_index]

            generated_text += next_char
            generated_text = generated_text[1:]

            sys.stdout.write(next_char)
            sys.stdout.flush()
        print()

코드의 실행은 매우 결과가 길기때문에 생략합니다. 에포크당 200초가량 걸립니다. 결과는 역자분의 결과로 확인하면 더 좋을 것 같습니다. (아직 ipynb파일을 올리기엔 코드가 너무 지저분해서 부끄럽네요.)

결과를 보면 알 수 있지만, 온도가 낮을 때는 문장의 완성도가 비교적 높습니다. 실제 존재하는 단어와 비교적 안정적인 구조, 반복적이고 예상되는 텍스트를 만듭니다. 하지만 높은 온도의 경우 문장의 구조가 무너지며, 실제 존재하는 듯한 단어를 만들어냅니다. mestoped와 같은 단어가 만들어집니다. 그렇기에 창의적이고 안정적인 온도는 중간의 온도인 0.5정도가 적당합니다.

더 많은 데이터에서 크고 깊은 모델을 훈련하면 이보다 더 좋은 실제와 같은 텍스트 샘플을 만들 수 있습니다. 하지만 우연이 아닌 의미 있는 텍스트 생성은 무리가 있습니다.

8.1.5 정리

생략

8.2 딥드림

딥드림(DeepDream) 은 합성곱 신경망이 학습한 표현을 사용하여 예술적으로 이미지를 조작하는 기법입니다. 텐서플로가 공개되기 전 2015년 여름 구글이 카페 딥러닝 라이브러리를 사용하여 구현한 것을 처음 공개했습니다.

딥드림 알고리즘은 5장에서 소개한 컨브넷을 거꾸로 실행하는 컨브넷 필터 시각화 기법과 거의 동일합니다. 컨브넷 상위 층에 있는 특정 필터의 활성화를 극대화하기 위해 컨브넷의 입력에 경사 상승법을 적용합니다. 몇 개의 사소한 차이를 빼면 딥드림도 동일한 아이디어를 사용합니다.

  • 딥드림에서 특정 필터가 아니라 전체 층의 활성화를 최대화합니다. 한꺼번에 많은 특성을 섞어 시각화합니다.
  • 빈 이미지나 노이즈가 조금 있는 입력이 아니라 이미 가지고 있는 이미지를 사용합니다. 그 결과 기존 시각 패턴을 바탕으로 이미지의 요소를 다소 예술적인 스타일로 왜곡시킵니다.
  • 입력 이미지는 시각 품질을 높이기 위해 여러 다른 스케일(옥타브(octave) : 이미지 크기를 일정한 비율로 연속적으로 줄이거나 늘리는 방식)로 처리합니다.

8.2.1 케라스 딥드림 구현

ImageNet에서 훈련된 컨브넷을 가지고 시작합니다. 케라스에는 이렇게 사용할 수 있는 컨브넷은 다양합니다. VGG16, VGG19, Xception, ResNet50 등입니다. 모두 딥드림에 사용할 수 있지만 컨브넷 구조에 따라 다른 그림이 생성됩니다. 예제에서는 인셉션 모델을 이용합니다. 케라스의 인셉션 V3 모델을 사용합니다.

# 코드 8-8 사전 훈련된 인셉션 V3 모델 로드하기
from keras.applications import inception_v3
from keras import backend as K

# 모델을 훈련하지 않습니다. 이 명령은 모든 훈련 연산을 비활성화합니다
K.set_learning_phase(0)

# 합성곱 기반층만 사용한 인셉션 V3 네트워크를 만듭니다. 사전 훈련된 ImageNet 가중치와 함께 모델을 로드합니다
model = inception_v3.InceptionV3(weights='imagenet', include_top=False)

손실을 계산합니다. 경사 상승법으로 최대화할 값입니다. 5장 필터 시각화에서 특정 층의 필터 값을 최대화했습니다. 여기서는 여러 층에 있는 모든 필터 활성화를 동시에 최대화합니다. 특별히 상위 층에 있는 활성화의 L2 노름에 대한 가중치 합을 최대화합니다. 층에 따라 시각화에 영향을 미치므로 층은 파라미터로 손쉽게 바꿀 수 있어야 합니다. 먼저 임의로 4개의 층을 선택해서 해봅니다.

# 코드 8-9 딥드림 설정하기

# 층 이름과 계수를 매핑한 딕셔너리.
# 최대화하려는 손실에 층의 활성화가 기여할 양을 정합니다.
# 층 이름은 내장된 인셉션 V3 애플리케이션에 하드코딩되어 있는 것입니다.
# model.summary()를 사용하면 모든 층 이름을 확인할 수 있습니다
layer_contributions = {
    'mixed2': 0.2,
    'mixed3': 3.,
    'mixed4': 2.,
    'mixed5': 1.5,
}

이제 손실 텐서를 정의합니다. 코드 8-9에서 선택한 층의 활성화에 대한 L2 노름의 가중치 합입니다.

# 코드 8-10 최대화할 손실 정의하기

# 층 이름과 층 객체를 매핑한 딕셔너리를 만듭니다.
layer_dict = dict([(layer.name, layer) for layer in model.layers])

# 손실을 정의하고 각 층의 기여분을 이 스칼라 변수에 추가할 것입니다
loss = K.variable(0.)
for layer_name in layer_contributions:
    coeff = layer_contributions[layer_name]
    # 층의 출력을 얻습니다
    activation = layer_dict[layer_name].output

    scaling = K.prod(K.cast(K.shape(activation), 'float32'))
    # 층 특성의 L2 노름의 제곱을 손실에 추가합니다. 이미지 테두리는 제외하고 손실에 추가합니다.
    loss += coeff * K.sum(K.square(activation[:, 2: -2, 2: -2, :])) / scaling

그 다음 경사 상승법 과정을 준비합니다.

# 코드 8-11 경사 상승법 과정
# 이 텐서는 생성된 딥드림 이미지를 저장합니다
dream = model.input

# 손실에 대한 딥드림 이미지의 그래디언트를 계산합니다
grads = K.gradients(loss, dream)[0]

# 그래디언트를 정규화합니다(이 기교가 중요합니다)
grads /= K.maximum(K.mean(K.abs(grads)), 1e-7)

# 주어진 입력 이미지에서 손실과 그래디언트 값을 계산할 케라스 Function 객체를 만듭니다
outputs = [loss, grads]
fetch_loss_and_grads = K.function([dream], outputs)

def eval_loss_and_grads(x):
    outs = fetch_loss_and_grads([x])
    loss_value = outs[0]
    grad_values = outs[1]
    return loss_value, grad_values

# 이 함수는 경사 상승법을 여러 번 반복하여 수행합니다
def gradient_ascent(x, iterations, step, max_loss=None):
    for i in range(iterations):
        loss_value, grad_values = eval_loss_and_grads(x)
        if max_loss is not None and loss_value > max_loss:
            break
        print('...', i, '번째 손실 :', loss_value)
        x += step * grad_values
    return x

마지막으로 딥드림 알고리즘입니다. 먼저 이미지를 처리하기 위한 스케일 리스트를 정의합니다. 스케일은 이전 스케일보다 1.4배 큽니다. 작은 이미지로 시작해서 점점 크기를 키웁니다.

스케일을 연속적으로 증가시키면서 이미지 상세를 많이 잃지 않도록 간단한 기교를 사용합니다. 스케일을 늘린 후 이미지에 손실된 디테일을 재주입합니다. 작은 이미지와 큰 이미지 사이즈 차이를 계산하여 디테일을 구합니다.

# 코드 8-12 연속적인 스케일에 걸쳐 경사 상승법 실행하기

import numpy as np

# 하이퍼파라미터를 바꾸면 새로운 효과가 만들어집니다
step = 0.01  # 경상 상승법 단계 크기
num_octave = 3  # 경사 상승법을 실행할 스케일 단계 횟수
octave_scale = 1.4  # 스케일 간의 크기 비율
iterations = 20  # 스케일 단계마다 수행할 경사 상승법 횟수

# 손실이 10보다 커지면 이상한 그림이 되는 것을 피하기 위해 경사 상승법 과정을 중지합니다
max_loss = 10.

# 사용할 이미지 경로를 씁니다
base_image_path = './einstein.jpeg'

# 기본 이미지를 넘파이 배열로 로드합니다
img = preprocess_image(base_image_path)

# 경사 상승법을 실행할 스케일 크기를 정의한 튜플의 리스트를 준비합니다
original_shape = img.shape[1:3]
successive_shapes = [original_shape]
for i in range(1, num_octave):
    shape = tuple([int(dim / (octave_scale ** i)) for dim in original_shape])
    successive_shapes.append(shape)

# 이 리스트를 크기 순으로 뒤집습니다
successive_shapes = successive_shapes[::-1]

# 이미지의 넘파이 배열을 가장 작은 스케일로 변경합니다
original_img = np.copy(img)
shrunk_original_img = resize_img(img, successive_shapes[0])

for shape in successive_shapes:
    print('처리할 이미지 크기', shape)
    img = resize_img(img, shape)
    img = gradient_ascent(img,
                          iterations=iterations,
                          step=step,
                          max_loss=max_loss)
    upscaled_shrunk_original_img = resize_img(shrunk_original_img, shape)
    same_size_original = resize_img(original_img, shape)
    lost_detail = same_size_original - upscaled_shrunk_original_img

    img += lost_detail
    shrunk_original_img = resize_img(original_img, shape)
    save_img(img, fname='dream_at_scale_' + str(shape) + '.png')

save_img(img, fname='./final_dream.png')

이 코드를 이용하기 위해서는 아래의 함수를 사용해야합니다. 넘파이 배열 기반의 함수입니다. Scipy를 이용합니다.

# 코드 8-13 유틸리티 함수
import scipy
from keras.preprocessing import image

def resize_img(img, size):
    img = np.copy(img)
    factors = (1,
               float(size[0]) / img.shape[1],
               float(size[1]) / img.shape[2],
               1)
    return scipy.ndimage.zoom(img, factors, order=1)


def save_img(img, fname):
    pil_img = deprocess_image(np.copy(img))
    image.save_img(fname, pil_img)


def preprocess_image(image_path):
    # 사진을 열고 크기를 줄이고 인셉션 V3가 인식하는 텐서 포맷으로 변환하는 유틸리티 함수
    img = image.load_img(image_path)
    img = image.img_to_array(img)
    img = np.expand_dims(img, axis=0)
    img = inception_v3.preprocess_input(img)
    return img


def deprocess_image(x):
    # 넘파이 배열을 적절한 이미지 포맷으로 변환하는 유틸리티 함수
    if K.image_data_format() == 'channels_first':
        x = x.reshape((3, x.shape[2], x.shape[3]))
        x = x.transpose((1, 2, 0))
    else:
        # inception_v3.preprocess_input 함수에서 수행한 전처리 과정을 복원합니다
        x = x.reshape((x.shape[1], x.shape[2], 3))
    x /= 2.
    x += 0.5
    x *= 255.
    x = np.clip(x, 0, 255).astype('uint8')
    return x

책에서는 자연사진으로 적용하였지만, 저는 아인슈타인 사진으로 적용해보았습니다.

sorry for einstein

손실 층에 따라 다양한 결과가 나올 수 있습니다. 하위 층은 지역적이고 비교적 덜 추상적인 표현을 가지고 있기 때문에 딥드림 이미지에 기하학적 패턴이 많이 생깁니다. 상위층은 눈, 새의 깃털처럼 ImageNet에 많이 등장하는 물체를 기반으로 뚜렷이 구분되는 시각 패턴을 만듭니다.

8.2.2 정리

  • 재미있는 결과가 만들어지고, 때로는 환각제 때문에 시야가 몽롱해진 사람이 만든 이미지 같기도 합니다.
  • 이미지 모델이나 컨브넷에 국한되지 않고, 음성과 음악 등에도 적용될 수 있다고 합니다.(EDM이 만들어질려나요??)

8.3 뉴럴 스타일 트랜스퍼

제가 딥러닝을 시작하며 가장 하고 싶었던 부분입니다. 딥드림 이외에 딥러닝을 사용하여 이미지를 변경하는 또 다른 주요 분야는 뉴럴 스타일 트랜스퍼(neural style transfer) 입니다. 이는 2015년에 소개되어 많은 변종이 생겼습니다.

스마트폰의 사진 앱에도 사용되기도 하며, 인공지능의 예시로 많이 사용되고 있다고 합니다. 이 절에서는 간단하게 원본 논문에 소개한 방식을 사용합니다. 뉴럴 스타일 트랜스퍼는 타깃 이미지의 콘텐츠를 보존면서 참조 이미지의 스타일을 타깃 이미지에 적용합니다. 유명한 예시로는 다음과 같은 예시들이 있습니다.

famous example of nst

스타일이란 질감, 색깔, 이미지에 있는 다양한 크기의 시각 요소를 의미합니다. 콘텐츠는 이미지에 있는 고수준의 대형 구조를 말합니다. 예를 들어 고흐의 동그랗고 유연한 붓터치가 스타일, 원본 건물 사진이 콘텐츠라고 할 수 있습니다.

텍스처 생성과 밀접하게 연관된 스타일 트랜스퍼의 아이디어는 2015년 뉴럴 스타일 트랜스퍼가 개발되기 이전에 이미 이미지 처리 분야에서 오랜 역사를 가지고 있습니다. 딥러닝을 기반으로 한 스타일 트랜스퍼 구현은 고전적인 컴퓨터 비전 기법으로 만든 것과는 비견할 수 없는 결과를 제공합니다.

이는 딥러닝 알고리즘과 핵심은 동일합니다. 목표를 표현한 손실 함수를 정의하고 이 손실을 최소화합니다. 여기서 원하는 것은 참조의 이미지 스타일을 적용하면서 원본 이미지의 콘텐츠를 보존하는 것입니다. 수학적으로 접근하면 다음과 같습니다.

loss = distance(style(reference_image)-style(generated_image)) + distance(content(original_image)-content(generated_image))

여기서 distance는 L2 norm 같은 norm 함수입니다. content 함수는 이미지의 콘텐츠 표현을 계산합니다. style 함수는 이미지의 스타일 표현을 계산합니다. 심층 합성곱 신경망을 사용하여 style과 content 함수를 수학적으로 정의할 수 있다는 것을 알았으니 더 알아봅시다.

8.3.1 콘텐츠 손실

하위 층의 활성환즌 이미지에 관한 국부적인 정보를 담고 있고, 상위 층의 활성화는 이미지에 대한 전역적이고 추상적인 정보를 담고 있다는 것을 알고 있습니다. 이는 즉 컨브넷 층의 활성화는 이미지를 다른 크기의 콘텐츠로 분해한다고 볼 수 있습니다.

타깃 이미지와 생성된 이미지를 사전 훈련된 컨브넷에 주입하여 상위 층의 활성화를 계산합니다. 이 두 값 사이의 L2 norm을 콘텐츠 손실로 사용할 수 있습니다. 상위 층에서 보았을 때 이미지의 전체적인 구조는 유사하게 만들 것입니다.

8.3.2 스타일 손실

콘텐츠 손실은 하나의 상위 층만 사용합니다. 게티스 등이 정의한 스타일 손실은 컨브넷의 여러 층을 사용합니다. 하나의 스타일이 아니라 참조 이미지에서 컨브넷이 추출한 모든 크기의 스타일을 잡아야 합니다.

게티스 등은 층의 활성화 출력의 그람 행렬(Gram matrix) 을 스타일 손실로 사용했습니다. 그람 행렬은 층의 특성 맵들의 내적입니다. 내적은 층의 특성 사이에 있는 상관관계를 표현한다고 이해할 수 있습니다. 이런 특성 상관관계는 특정 크기의 공간적인 패턴 통계를 잡아냅니다. 경험에 비추어 보았을 때 이는 층에서 찾은 텍스처에 대응됩니다.

스타이 참조 이미지와 생성된 이미지로 층의 활성화를 계산합니다. 스타일 손실은 그 안에 내재된 상관관계를 비슷하게 보존하는 것이 목적입니다. 스타일 참조 이미지와 생성된 이미지에서 여러 크기의 텍스처가 비슷하게 보이도록 만듭니다.

이제 2015년 뉴럴 스타일 트랜스퍼 원본 알고리즘을 케라스로 구현해봅시다.

8.3.3 케라스에서 뉴럴 스타일 트랜스퍼 구현하기

뉴럴 스타일 트랜스퍼는 사전 훈련된 컨브넷 중 어떤 것을 사용해도 구현할 수 있습니다. 책에서는 VGG19(VGG16 + 3개 층)을 사용합니다. 일반적인 과정은 다음과 같습니다.

  1. 스타일 참조 이미지, 타깃 이미지, 생성된 이미지를 위해 VGG19의 층 활성화를 동시에 계산하는 네트워크를 설정합니다.
  2. 세 이미지에서 계산한 층 활성화를 사용하여 앞서 설명한 손실 함수를 정의합니다. 이 손실을 최소화하여 스타일 트랜스퍼를 구현할 것입니다.
  3. 손실 함수를 최소화할 경사 하강법 과정을 설정합니다.

스타일 참조 이미지와 타깃 이미지의 경로를 정의하는 것부터 시작합니다. 처리할 이미지의 크기는 비슷한게 좋습니다. 모두 높이가 400픽셀이 되도록 크기를 변경합니다.

# 코드 8-14 변수 초깃값 정의하기

from keras.preprocessing.image import load_img, img_to_array, save_img

# 변환하려는 이미지 경로
target_image_path = './datasets/portrait.png'
# 스타일 이미지 경로
style_reference_image_path = './datasets/popova.jpg'

# 생성된 사진의 차원
width, height = load_img(target_image_path).size
img_height = 400
img_width = int(width * img_height / height)

후에 VGG19 컨브넷에 입출력할 이미지의 로드, 전처리, 사후 처리를 위해 유틸리티 함수를 정의합니다.

# 코드 8-15 유틸리티 함수
import numpy as np
from keras.applications import vgg19

def preprocess_image(image_path):
    img = load_img(image_path, target_size=(img_height, img_width))
    img = img_to_array(img)
    img = np.expand_dims(img, axis=0)
    img = vgg19.preprocess_input(img)
    return img

def deprocess_image(x):
    # ImageNet의 평균 픽셀 값을 더합니다
    x[:, :, 0] += 103.939
    x[:, :, 1] += 116.779
    x[:, :, 2] += 123.68
    # 'BGR'->'RGB'
    x = x[:, :, ::-1]
    x = np.clip(x, 0, 255).astype('uint8')
    return x

VGG19 네트워크를 설정해야합니다. 스타일 참조 이미지, 타깃 이미지 그리고 생성된 이미지가 담긴 플레이스홀더로 이루어진 배치를 입력으로 받습니다. 플레이스홀더는 심볼릭 텐서로, 넘파이 배열로 밖에서 값을 제공해야 합니다. 스타일 참조 이미지와 타깃 이미지는 이미 준비된 데이터이므로 K.constant를 사용하여 정의합니다. 반면에 플레이스홀더에 담길 생성된 이미지는 계속 바뀝니다.

# 코드 8-16 사전 훈련된 VGG19 네트워크를 로딩하고 3개의 이미지에 적용하기
from keras import backend as K

target_image = K.constant(preprocess_image(target_image_path))
style_reference_image = K.constant(preprocess_image(style_reference_image_path))

# 생성된 이미지를 담을 플레이스홀더
combination_image = K.placeholder((1, img_height, img_width, 3))

# 세 개의 이미지를 하나의 배치로 합칩니다
input_tensor = K.concatenate([target_image,
                              style_reference_image,
                              combination_image], axis=0)

# 세 이미지의 배치를 입력으로 받는 VGG 네트워크를 만듭니다.
# 이 모델은 사전 훈련된 ImageNet 가중치를 로드합니다
model = vgg19.VGG19(input_tensor=input_tensor,
                    weights='imagenet',
                    include_top=False)
print('모델 로드 완료.')

콘텐츠 손실을 정의해야합니다. VGG19 컨브넷의 상위 층은 타깃 이미지와 생성된 이미지를 동일하게 바라보아야 합니다.

# 코드 8-17 콘텐츠 손실
def content_loss(base, combination):
    return K.sum(K.square(combination - base))

스타일 손실은 유틸리티 함수를 사용하여 입력 행렬의 그람 행렬을 계산합니다. 이 행렬은 원본 특성 행렬의 상관관계를 기록한 행렬입니다.

# 코드 8-18 스타일 손실
def gram_matrix(x):
    features = K.batch_flatten(K.permute_dimensions(x, (2, 0, 1)))
    gram = K.dot(features, K.transpose(features))
    return gram


def style_loss(style, combination):
    S = gram_matrix(style)
    C = gram_matrix(combination)
    channels = 3
    size = img_height * img_width
    return K.sum(K.square(S - C)) / (4. * (channels ** 2) * (size ** 2))

두 손실에 하나를 더 추가해야합니다. 생성된 이미지의 픽셀을 사용하여 계산하는 총 변위 손실(variation loss) 입니다. 이는 생성된 이미지가 공간적인 연속성을 가지도록 도와주며 픽셀의 격자 무늬가 과도하게 나타나는 것을 막아줍니다. 이를 일종의 규제 항으로 해석할 수 있습니다.

# 코드 8-19 총 변위 손실
def total_variation_loss(x):
    a = K.square(
        x[:, :img_height - 1, :img_width - 1, :] - x[:, 1:, :img_width - 1, :])
    b = K.square(
        x[:, :img_height - 1, :img_width - 1, :] - x[:, :img_height - 1, 1:, :])
    return K.sum(K.pow(a + b, 1.25))

최소화할 손실은 이 세 손실의 가중치 평균입니다. 콘텐츠 손실은 block5_conv2 층 하나만 사용해서 계산합니다. 스타일 손실을 계산하기 위해서는 하위 층과 상위 층에 걸쳐 여러 층을 사용합니다. 마지막에 총 변위 손실을 추가합니다.

사용하는 스타일 참조 이미지와 콘텐츠 이미지에 따라 content_weight 계수를 조정하는 것이 좋습니다. content_weight가 높으면 생성된 이미지에 타깃 콘텐츠가 더 많이 나타나게 됩니다.

# 코드 8-20 최소화할 최종 손실 정의하기

# 층 이름과 활성화 텐서를 매핑한 딕셔너리
outputs_dict = dict([(layer.name, layer.output) for layer in model.layers])
# 콘텐츠 손실에 사용할 층
content_layer = 'block5_conv2'
# 스타일 손실에 사용할 층
style_layers = ['block1_conv1',
                'block2_conv1',
                'block3_conv1',
                'block4_conv1',
                'block5_conv1']
# 손실 항목의 가중치 평균에 사용할 가중치
total_variation_weight = 1e-4
style_weight = 1.
content_weight = 0.025

# 모든 손실 요소를 더해 하나의 스칼라 변수로 손실을 정의합니다
loss = K.variable(0.)
layer_features = outputs_dict[content_layer]
target_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :]
loss += content_weight * content_loss(target_image_features,
                                      combination_features)
for layer_name in style_layers:
    layer_features = outputs_dict[layer_name]
    style_reference_features = layer_features[1, :, :, :]
    combination_features = layer_features[2, :, :, :]
    sl = style_loss(style_reference_features, combination_features)
    loss += (style_weight / len(style_layers)) * sl
loss += total_variation_weight * total_variation_loss(combination_image)

마지막으로 경사 하강법 단계를 설정합니다. 게티스의 원래 논문에서 L-BFGS 알고리즘을 사용하였고 예제에서도 이를 사용합니다. 전의 딥드림 예제와 가장 크게 차이나는 부분입니다. Scipy에 구현되어 있는데 두 가지 제약 사항이 존재합니다.

  • 손실 함수 값과 그래디언트 값을 별개의 함수로 전달해야 합니다.
  • 이 함수는 3D 이미지 배열이 아닌 1차원 벡터만 처리할 수 있습니다.

손실 함수 값과 그래디언트 값을 따로 계산하는 것은 비효율적입니다. 두 게산 사이에 중복되는 계산이 많기 때문입니다. 한번에 계산하는 것보다 2배정도의 속도차이가 납니다. 이를 피하기 위해 Evaluator란 이름의 파이썬 클래스를 만듭니다. 처음 호출할 때 손실 값을 반환하면서 다음 호출을 위해 그래디언트를 캐싱합니다.

# 코드 8-21 경사 하강법 단계 설정하기

# 손실에 대한 생성된 이미지의 그래디언트를 구합니다
grads = K.gradients(loss, combination_image)[0]

# 현재 손실과 그래디언트의 값을 추출하는 케라스 Function 객체입니다
fetch_loss_and_grads = K.function([combination_image], [loss, grads])


class Evaluator(object):

    def __init__(self):
        self.loss_value = None
        self.grads_values = None

    def loss(self, x):
        assert self.loss_value is None
        x = x.reshape((1, img_height, img_width, 3))
        outs = fetch_loss_and_grads([x])
        loss_value = outs[0]
        grad_values = outs[1].flatten().astype('float64')
        self.loss_value = loss_value
        self.grad_values = grad_values
        return self.loss_value

    def grads(self, x):
        assert self.loss_value is not None
        grad_values = np.copy(self.grad_values)
        self.loss_value = None
        self.grad_values = None
        return grad_values

evaluator = Evaluator()

마지막으로 싸이파이 L-BFGS 알고리즘을 사용하여 경사 하강법 단계를 수행합니다. 알고리즘 반복마다 생성된 이미지를 저장합니다.

# 코드 8-22 스타일 트랜스퍼 반복 루프

from scipy.optimize import fmin_l_bfgs_b
import time

result_prefix = 'style_transfer_result'
iterations = 20

# 뉴럴 스타일 트랜스퍼의 손실을 최소화하기 위해 생성된 이미지에 대해 L-BFGS 최적화를 수행합니다
# 초기 값은 타깃 이미지입니다
# scipy.optimize.fmin_l_bfgs_b 함수가 벡터만 처리할 수 있기 때문에 이미지를 펼칩니다.
x = preprocess_image(target_image_path)
x = x.flatten()
for i in range(iterations):
    print('반복 횟수:', i)
    start_time = time.time()
    x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x,
                                     fprime=evaluator.grads, maxfun=20)
    print('현재 손실 값:', min_val)
    # 생성된 현재 이미지를 저장합니다
    img = x.copy().reshape((img_height, img_width, 3))
    img = deprocess_image(img)
    fname = result_prefix + '_at_iteration_%d.png' % i
    save_img(fname, img)
    end_time = time.time()
    print('저장 이미지: ', fname)
    print('%d 번째 반복 완료: %ds' % (i, end_time - start_time))

한 에포크당 11초 정도 소모되는 것을 알 수 있습니다. 다음과 같은 결과를 만들어볼 수 있었습니다.

피카소의 울고있는 여인을 이용한 내 모습 변형하기

이 기법은 이미지의 텍스처를 바꾸거나 텍스처를 전이한 것임을 기억해야합니다. 스타일 이미지의 텍스처가 두드러지고 비슷한 패턴이 많을 때 잘 작동합니다. 또한 콘텐츠 타깃을 알아보기 위해 수준 높은 이해가 필요하지 않을 때 잘 작동합니다. 일반적으로 인물 사진의 스타일을 다른 인물 사진으로 옮기는 것처럼 아주 추상적인 기교는 만들지 못합니다. AI보다는 고전적인 시그널 처리에 가깝기 때문에 마술 같은 결과는 기대를 안하는게 좋습니다.

스타일 트랜스퍼 알고리즘은 느리지만 간단한 변환을 수행하므로 작고 빠른 컨브넷을 사용하여 학습할 수 있습니다. 물론 적절한 양의 훈련 데이터가 있어야 합니다. 먼저 고정된 스타일 참조 이미지에 대해서 여기에서 소개한 방법으로 입력-출력 훈련 샘플을 많이 생성합니다. 그 다음 이 스타일 변환을 학습하는 간단한 컨브넷을 훈련하면 스타일 트랜스퍼를 빠르게 수행할 수 있습니다. 그냥 이 작은 컨브넷을 통과시키면 됩니다.

8.3.4 정리

생략

8.4 변이형 오토인코더를 사용한 이미지 생성

이미지의 잠재 공간에서 샘플링해서 완전히 새로운 이미지나 기존 이미지를 변형하는 방식이 현재 가장 인기 있고 성공적으로 창조적 AI 애플리케이션을 만들 수 있는 방법입니다. 이 절과 다음 절에서 이미지 생성에 관계된 고급 개념을 살펴봅니다. 이 분야의 주요 기법인 변이형 오토인코더(Variational AutoEncoders, VAE)적대적 생성 네트워크(Generative Adversarial Network, GAN) 의 상세 구현을 함께 다루겠습니다. 이를 이용하면 사진 뿐만 아니라 음성, 영상 등 다양한 곳에 적용할 수 있습니다.

8.4.1 이미지의 잠재 공간에서 샘플링하기

이미지 생성의 핵심 아이디어는 각 포인트가 실제와 같은 이미지로 매핑될 수 있는 저차원 잠재 공간의 표현을 만드는 것입니다. 잠재 공간의 한 포인트를 입력으로 받아 이미지를 출력하는 모듈을 생성자(generator) 또는 디코더(decoder) 라고 부릅니다. 잠재 공간이 만들어지면 여기서 포인트 하나를 특정하여 또는 무작위로 샘플링할 수 있습니다. 그다음 이미지 공간으로 매핑하여 이전에 본 적 없는 이미지를 생성합니다.

두 기법은 이미지의 잠재 공간 표현을 학습하는 2개의 전략이고 각각 나름의 특징을 가집니다. VAE는 구조적인 잠재 공간을 학습하는 데 뛰어납니다. 이 공간에서 특정 방향은 데잉터에서 의미 있는 변화의 방향을 인코딩합니다. GAN은 매우 실제 같은 이미지를 만듭니다. 여기에서 만든 잠재 공간은 구조적이거나 연속성이 없을 수 있습니다.

VAE를 사용하여 톰 화이트가 생성한 얼굴의 연속 공간

8.4.2 이미지 변형을 위한 개념 벡터

6장에서 단어 임베딩을 다룰 때 이미 개념 벡터(concept vector) 에 대한 아이디어를 얻었습니다. 잠재 공간이나 임베딩 공간이 주어지면 이 공간의 어떤 방향은 원본 데이터의 흥미로운 변화를 인코딩한 축일 수 있습니다. 에를 들면 웃음 과 같은 요소가 될 수 있습니다.

다음은 유명 인사의 얼굴 데이터셋에서 훈련한 웃음 벡터 예입니다.

VAE를 사용하여 톰 화이트가 생성한 얼굴의 연속 공간

8.4.3 변이형 오토인코더

2013년 12월 킹마와 웰링, 2014년 1월 르젠드, 무함마드, 위스트라가 동시에 발견한 변형 오토인코더는 생성 모델의 한 종류로 개념 벡터를 사용하여 이미지를 변형하는 데 아주 적절합니다. 오토인코더는 입력을 저차원 잠재 공간으로 인코딩한 후 디코딩하여 복원하는 네트워크입니다. 변이형은 딥러닝과 베이즈 추론의 아이디어를 혼합합니다.

오토인코더: 입력 x를 압축된 표현으로 매핑하고 이를 디코딩하여 x'를 복원

고전적 오토인코더는 이미지를 입력받아 인코더 모듈을 사용하여 잠재 벡터 공간으로 매핑합니다. 그 다음 디코더 모듈을 사용해서 원본 이미지와 동일한 차원으로 복원하여 출력합니다. 오토 인코더는 입력 이미지와 동일한 이미지를 타깃 데이터로 사용하여 훈련합니다. 즉 원본 입력을 재구성하는 방법을 학습합니다. 제약을 통해 잠재 공간의 표현을 구체적으로 학습합니다. 일반적으로 저차원이고 희소하도록 제약을 가합니다. 인코더는 입력 데이터의 정보를 적은 수의 비트에 압축하기 위해 노력합니다.

아이디어는 좋지만 실제로는 기능이 매우 부족합니다. 압축도 부족하고 다양한 단점이 있습니다. 그렇기에 VAE는 오토인코더에 약간의 통계 기법을 추가하여 연속적이고 구조적인 잠재 공간을 학습하게 만들었습니다.

입력 이미지를 잠재 공간의 고정된 코딩으로 압축하는 대신 VAE는 이미지를 어떤 통계 분포의 파라미터로 변환합니다. 이는 입력 이미지가 통계적 과정을 통해서 생성되었다고 가정하여 인코딩과 디코딩하는 동안 무작위성이 필요하하다는 것을 의미합니다. VAE는 평균과 분산 파라미터를 사용하여 이 분포에서 무작위로 하나의 샘플을 추출합니다. 이 샘플을 디코딩하여 원본 입력으로 복원합니다. 이런 무작위한 과정은 안정성을 향상하고 잠재 공간 어디서든 의미 있는 표현을 인코딩하도록 만듭니다. 즉 잠재 공간에서 샘플링한 모든 포인트는 유효한 출력으로 디코딩됩니다.

VAE는 이미지 2개를 벡터 z_mean과 z_log_var로 매핑. 이 벡터는 잠재 공간상 확률 분포를 정의하고 디코딩하기 위해 잠재 공간의 포인트를 샘플링하는데 사용한다.

기술적으로 보면 VAE는 다음과 같이 작동합니다.

  1. 인코더 모듈이 입력 샘플 input_img를 잠재 공간의 두 파라미터 z_mean과 z_log_var로 변환합니다.
  2. 입력 이미지가 생성되었다고 가정한 잠재 공간의 정규 분포에서 포인트 z를 z = z_mean + exp(0.5 * z_log_var) * epsilon 처럼 무작위로 샘플링합니다. epsilon은 작은 값을 가진 랜덤 텐서입니다.
  3. 디코더 모듈은 잠재 공간의 이 포인트를 원본 입력 이미지로 매핑하여 복원합니다.

epsilon이 무작위로 만들어지기 때문에 image_img를 인코딩한 잠재 공간의 위치(z_mean)에 가까운 포인트는 input_img와 비슷한 이미지로 디코딩될 것입니다. 이는 잠재 공간을 연속적이고 의미 있는 공간으로 만들어 줍니다. 잠재 공간의 저차원 연속성은 잠재 공간에서 모든 방향이 의미 있는 데이터 변화의 축을 인코딩하도록 만듭니다. 결국 잠재 공간은 매우 구조적이고 개념 벡터로 다루기에 적합해집니다.

VAE 파라미터는 2개의 손실 함수로 훈련합니다. 디코딩된 샘플이 원본 입력과 동일하도록 만드는 재구성 손실(reconstruction loss) 과 잠재 공간을 잘 형성하고 훈련 데이터에 과대적합을 줄이는 규제 손실(regularization loss) 입니다. 케라스의 VAE 구현을 간단히 보면 다음과 같습니다.

# 입력을 평균과 분산 파라미터로 인코딩합니다
z_mean, z_log_variance = encoder(input_img)

# 무작위로 선택한 작은 epsilon 값을 사용해 잠재 공간의 포인트를 뽑습니다
z = z_mean + exp(z_log_variance) * epsilon

# z를 이미지로 디코딩합니다
reconstructed_img = decoder(z)

# 모델 객체를 만듭니다
model = Model(input_img, reconstructed_img)

# 입력 이미지와 재구성 이미지를 매핑한 오토인코더 모델을 훈련합니다.

그다음 이 모델을 재구성 손실과 규제 손실을 사용하여 훈련합니다. 다음 코드는 이미지를 잠재 공간상 확률 분포 파라미터로 매핑하는 인코더 네트워크입니다. 입력 이미지 x를 두 벡터 z_mean과 z_log_var로 매핑하는 간단한 컨브넷입니다.

# 코드 8-23 VAE 인코더 네트워크
import keras
from keras import layers
from keras import backend as K
from keras.models import Model
import numpy as np

img_shape = (28, 28, 1)
batch_size = 16
latent_dim = 2  # 잠재 공간의 차원: 2D 평면

input_img = keras.Input(shape=img_shape)

x = layers.Conv2D(32, 3,
                  padding='same', activation='relu')(input_img)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu',
                  strides=(2, 2))(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
shape_before_flattening = K.int_shape(x)

x = layers.Flatten()(x)
x = layers.Dense(32, activation='relu')(x)

z_mean = layers.Dense(latent_dim)(x)
z_log_var = layers.Dense(latent_dim)(x)

다음은 z_mean과 z_log_var를 사용하는 코드입니다. 이 두 파라미터가 input_img를 생성한 통계 분포의 파라미터라고 가정하고 잠재 공간 포인트 z를 생성합니다. 여기에서 (케라스의 백엔드 기능으로 만든) 일련의 코드를 Lambda 층으로 감쌉니다. 케라스에서는 모든 것이 층이므로 기본 층을 사용하지 않은 코드는 Lambda로 (또는 직접 만든 층으로) 감싸야 합니다.

# 코드 8-24 잠재 공간 샘플링 함수
def sampling(args):
    z_mean, z_log_var = args
    epsilon = K.random_normal(shape=(K.shape(z_mean)[0], latent_dim),
                              mean=0., stddev=1.)
    return z_mean + K.exp(z_log_var) * epsilon

z = layers.Lambda(sampling)([z_mean, z_log_var])

다음 코드는 디코더 구현입니다. 벡터 z를 이전 특성 맵 차원으로 크기를 바꾸고 몇 개의 합성곱 층을 사용해 최종 출력 이미지를 만듭니다. 최종 이미지는 원본 input_img와 차원이 같습니다.

# 코드 8-25 잠재 공간 포인트를 이미지로 매핑하는 VAE 디코더 네트워크
# Input에 z를 주입합니다
decoder_input = layers.Input(K.int_shape(z)[1:])

# 입력을 업샘플링합니다
x = layers.Dense(np.prod(shape_before_flattening[1:]),
                 activation='relu')(decoder_input)

# 인코더 모델의 마지막 Flatten 층 직전의 특성 맵과 같은 크기를 가진 특성 맵으로 z의 크기를 바꿉니다
x = layers.Reshape(shape_before_flattening[1:])(x)

# Conv2DTranspose 층과 Conv2D 층을 사용해 z를 원본 입력 이미지와 같은 크기의 특성 맵으로 디코딩합니다
x = layers.Conv2DTranspose(32, 3,
                           padding='same', activation='relu',
                           strides=(2, 2))(x)
x = layers.Conv2D(1, 3,
                  padding='same', activation='sigmoid')(x)
# 특성 맵의 크기가 원본 입력과 같아집니다

# 디코더 모델 객체를 만듭니다
decoder = Model(decoder_input, x)

# 모델에 z를 주입하면 디코딩된 z를 출력합니다
z_decoded = decoder(z)

일반적인 샘플 기준의 함수인 loss(y_true, y_pred) 형태는 VAE의 이중 손실에 맞지 않습니다. add_loss 내장 메서드를 사용하는 층을 직접 만들어 임의의 손실을 정의하겠습니다.

# 코드 8-26 VAE 손실을 계산하기 위해 직접 만든 층

class CustomVariationalLayer(keras.layers.Layer):

    def vae_loss(self, x, z_decoded):
        x = K.flatten(x)
        z_decoded = K.flatten(z_decoded)
        xent_loss = keras.metrics.binary_crossentropy(x, z_decoded)
        kl_loss = -5e-4 * K.mean(
            1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)
        return K.mean(xent_loss + kl_loss)

    def call(self, inputs):
        x = inputs[0]
        z_decoded = inputs[1]
        loss = self.vae_loss(x, z_decoded)
        self.add_loss(loss, inputs=inputs)
        # 출력 값을 사용하지 않습니다
        return x

# 입력과 디코딩된 출력으로 이 층을 호출하여 모델의 최종 출력을 얻습니다
y = CustomVariationalLayer()([input_img, z_decoded])

이제 모델 객체를 만들고 훈련할 준비가 되었습니다. 층에서 손실을 직접 다루기 때문에 compile 메서드에서 손실을 지정하지 않습니다(loss=None). 그 결과 훈련하는 동안 타깃 데이터를 전달하지 않아도 됩니다(다음 코드처럼 모델의 fit 메서드에 x_train만 전달합니다).

# 코드 8-27 VAE 훈련하기
from keras.datasets import mnist

vae = Model(input_img, y)
vae.compile(optimizer='rmsprop', loss=None)
vae.summary()

# MNIST 숫자 이미지에서 VAE를 훈련합니다
(x_train, _), (x_test, y_test) = mnist.load_data()

x_train = x_train.astype('float32') / 255.
x_train = x_train.reshape(x_train.shape + (1,))
x_test = x_test.astype('float32') / 255.
x_test = x_test.reshape(x_test.shape + (1,))

vae.fit(x=x_train, y=None,
        shuffle=True,
        epochs=10,
        batch_size=batch_size,
        validation_data=(x_test, None))
# 코드 8-28 2D 잠재 공간에서 포인트 그리드를 샘플링하여 이미지로 디코딩하기

import matplotlib.pyplot as plt
In [8]:
from scipy.stats import norm

# Display a 2D manifold of the digits
n = 15  # 15 × 15 숫자의 그리드를 출력합니다
digit_size = 28
figure = np.zeros((digit_size * n, digit_size * n))
# 싸이파이 ppf 함수를 사용하여 일정하게 떨어진 간격마다 잠재 변수 z의 값을 만듭니다
# 잠재 공간의 사전 확률은 가우시안입니다
grid_x = norm.ppf(np.linspace(0.05, 0.95, n))
grid_y = norm.ppf(np.linspace(0.05, 0.95, n))

for i, yi in enumerate(grid_x):
    for j, xi in enumerate(grid_y):
        z_sample = np.array([[xi, yi]])
        z_sample = np.tile(z_sample, batch_size).reshape(batch_size, 2)
        x_decoded = decoder.predict(z_sample, batch_size=batch_size)
        digit = x_decoded[0].reshape(digit_size, digit_size)
        figure[i * digit_size: (i + 1) * digit_size,
               j * digit_size: (j + 1) * digit_size] = digit

plt.figure(figsize=(10, 10))
plt.imshow(figure, cmap='Greys_r')
plt.show()

한 에포크당 45초 내외가 걸립니다. 결과는 다음과 같습니다.

VAE MNIST

샘플링된 숫자의 그리드는 다른 숫자 클래스 사이에서 완벽하게 연속된 분포를 보여줍니다. 잠재 공간의 한 경로를 따라서 한 숫자가 다른 숫자로 자연스럽게 바뀝니다. 이 공간의 특정 방향은 어떤 의미를 가집니다. 예를 들어 ‘6으로 가는 방향’, ‘9로 가는 방향’ 등입니다.

8.4.4 정리

생략

8.5 적대적 생성 신경망 소개

2014년 굿펠레우 등이 소개한 적대적 생성 신경망(GAN) 는 VAE와 다른 방법으로 이미지의 잠재 공간을 학습합니다. GAN은 생성된 이미지가 실제 이미지와 통계적으로 거의 구분이 되지 않도록 강제하여 아주 실제 같은 합성 이미지를 생성합니다. GAN은 2개의 네트워크로 구성됩니다.

  • 생성자 네트워크(generator network) : 랜덤 벡터를 입력으로 받아 이를 합성된 이미지로 디코딩합니다.
  • 판별자 네트워크(discriminator network) : 이미지를 입력으로 받아 훈련 세트에서 온 이미지인지, 생성자 네트워크가 만든 이미지 인지 판별합니다.

생성자 네트워크는 판별자 네트워크를 속이도록 훈련합니다. 훈련이 계속될수록 점점 더 실제와 같은 이미지를 생성하게 됩니다. 실제 이미지와 구분할 수 없는 인공적인 이미지를 만들어 판별자 네트워크가 두 이미지를 동일하게 보도록 만듭니다. 한편 판별자 네트워크는 생ㅅ겅된 이미지가 실제인지 판별하는 기준을 설정하면서 생성자의 능력 향상에 적응해 갑니다. 훈련이 끝나면 생성자는 입력 공간에 있는 어떤 포인트를 그럴듯한 이미지로 변환합니다. VAE와 달리 이 잠재 공간은 의미 있는 구조를 보장하지 않습니다. 특히 이 공간은 연속적이지 않습니다.

GAN은 최적화의 최솟값이 고정되지 않은 시스템입니다. 보통 경사 하강법은 고정된 손실 공간에서 언덕을 내려오는 방법입니다. GAN에서는 언덕을 내려오는 매 단계가 조금씩 전체 공간을 바꿉니다. 최적화 과정이 최솟값을 찾는 것이 아니라 두 힘 간의 평형점을 찾는 다이나믹 시스템입니다. 그렇기에 GAN은 훈련하기 어렵습니다.

아래는 GAN을 사용한 예시입니다.

GAN example

8.5.1 GAN 구현 방법

이 절에서는 케라스에서 가장 기본적인 형태의 GAN의 구현을 진행합니다. GAN 자체가 어려운 기술이기에 책에서는 구체적인 설명은 하지 않고 있습니다. 구체적인 구현은 심층 합성곱 GAN 입니다. 생성자와 판별자가 심층 컨브넷입니다.

CIFAR10 데이터셋의 이미지로 GAN을 훈련하겠습니다. 이 데이터셋은 32 * 32 크기의 RGB 이미지 5만 개로 이루어져 있고 10개의 클래스를 가집니다. 문제를 간단하게 하기 위해 “frog” 클래스의 이미지만 사용합니다.

GAN 구조는 다음과 같습니다.

  1. generator 네트워크는 (latent_dim,) 크기의 벡터를 (32, 32, 3) 크기의 이미지로 매핑합니다.
  2. discriminator 네트워크는 (32, 32, 3) 크기의 이미지가 진짜일 확률을 추정하여 이진 값으로 매핑합니다.
  3. 생성자와 판별자를 연결하는 gan 네트워크를 만듭니다. gan(x) = discriminator(generator(x))입니다. 이 네트워크는 잠재 공간의 벡터를 판별자의 평가로 매핑합니다. 즉 판별자는 생성자가 잠재 공간의 벡터를 디코딩한 것이 얼마나 현실적인지 평가합니다
  4. “진짜”/”가짜” 레이블과 함께 진짜 이미지와 가짜 이미지 샘플을 사용하여 판별자를 훈련합니다. 일반적인 이미지 분류 모델 훈련과 동일합니다.
  5. 생성자를 훈련하려면 gan 모델의 손실에 대한 생성자 가중치의 그래디언트를 사용합니다. 진짜의 방향으로 가중치를 이동함을 의미합니다.

8.5.2 훈련 방법

GAN을 훈련하고 튜닝하는 것은 어려운 작업입니다. 그렇기에 이론에 바탕을 둔 지침이 아닌 경험을 통해 발견된 점이 많습니다. GAN 생성자와 판별자를 구현하는 데 사용할 수 있는 몇 가지 기법은 다음과 같습니다. 논문에서 더 많은 정보를 얻을 수 있습니다.

  • 생정자의 마지막 활성화로 sigmoid 대신 tanh를 사용합니다.
  • 균등 분포가 아닌 정규 분포를 사용하여 잠재 공간에서 포인트를 샘플링합니다.
  • 무작위성을 사용합니다. 판별자에 드롭아웃을 사용하거나 판별자를 위해 레이블에 랜덤 노이즈를 추가합니다.
  • 희소한 그래디언트를 적게 사용합니다. 최대 풀링 대신 스트라이드 합성곱을 사용하여 다운샘플링, ReLU 대신 LeakyReLU 층 사용을 할 수 있습니다.
  • 생성자에서 픽셀 공간을 균일하게 다루지 못하여 생성된 이미지에서 체스판 모양이 종종 나타납니다. 이를 해결하기 위해 생성자와 판별자에서 스트라이드 Conv2DTranspose나 Conv2D를 사용할 때 스트라이드 크기로 나누어질 수 있는 커널 크기를 사용합니다.

8.5.3 생성자

먼저 벡터(훈련하는 동안 잠재 공간에서 무작위로 샘플링됩니다)를 후보 이미지로 변환하는 generator 모델을 만들어 보죠. GAN에서 발생하는 많은 문제 중 하나는 생성자가 노이즈 같은 이미지를 생성하는 데서 멈추는 것입니다. 판별자와 생성자 양쪽에 모두 드롭아웃을 사용하는 것이 해결 방법이 될 수 있습니다.

# 코드 8-29 GAN 생성자 네트워크
import keras
from keras import layers
import numpy as np

latent_dim = 32
height = 32
width = 32
channels = 3

generator_input = keras.Input(shape=(latent_dim,))

# 입력을 16 × 16 크기의 128개 채널을 가진 특성 맵으로 변환합니다
x = layers.Dense(128 * 16 * 16)(generator_input)
x = layers.LeakyReLU()(x)
x = layers.Reshape((16, 16, 128))(x)

# 합성곱 층을 추가합니다
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)

# 32 × 32 크기로 업샘플링합니다
x = layers.Conv2DTranspose(256, 4, strides=2, padding='same')(x)
x = layers.LeakyReLU()(x)

# 합성곱 층을 더 추가합니다
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)

# 32 × 32 크기의 1개 채널을 가진 특성 맵을 생성합니다
x = layers.Conv2D(channels, 7, activation='tanh', padding='same')(x)
generator = keras.models.Model(generator_input, x)
generator.summary()

8.5.4 판별자

다음은 후보 이미지(진짜 혹은 가짜)를 입력으로 받고 두 개의 클래스로 분류하는 discriminator 모델을 만들겠습니다. 이 클래스는 ‘생성된 이미지’ 또는 ‘훈련 세트에서 온 진짜 이미지’입니다.

# 코드 8-30 GAN 판별자 네트워크
discriminator_input = layers.Input(shape=(height, width, channels))
x = layers.Conv2D(128, 3)(discriminator_input)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Flatten()(x)

# 드롭아웃 층을 넣는 것이 아주 중요합니다!
x = layers.Dropout(0.4)(x)

# 분류 층
x = layers.Dense(1, activation='sigmoid')(x)

discriminator = keras.models.Model(discriminator_input, x)
discriminator.summary()

# 옵티마이저에서 (값을 지정하여) 그래디언트 클리핑을 사용합니다
# 안정된 훈련을 위해서 학습률 감쇠를 사용합니다
discriminator_optimizer = keras.optimizers.RMSprop(lr=0.0008, clipvalue=1.0, decay=1e-8)
discriminator.compile(optimizer=discriminator_optimizer, loss='binary_crossentropy')

8.5.5 적대적 네트워크

마지막으로 생성자와 판별자를 연결하여 GAN을 설정합니다. 훈련할 때 생성자가 판별자를 속이는 능력이 커지도록 학습합니다. 이 모델은 잠재 공간의 포인트를 “진짜” 또는 “가짜”의 분류 결정으로 변환합니다. 훈련에 사용되는 타깃 레이블은 항상 ‘진짜 이미지’입니다. gan을 훈련하는 것은 discriminator가 가짜 이미지를 보았을 때 진짜라고 예측하도록 만들기 위해 generator의 가중치를 업데이트하는 것입니다. 훈련하는 동안 판별자를 동결(학습되지 않도록)하는 것이 아주 중요합니다. gan을 훈련할 때 가중치가 업데이트되지 않습니다. 판별자의 가중치가 훈련하는 동안 업데이트되면 판별자는 항상 “진짜”를 예측하도록 훈련됩니다.

# 판별자의 가중치가 훈련되지 않도록 설정합니다(gan 모델에만 적용됩니다)
discriminator.trainable = False

gan_input = keras.Input(shape=(latent_dim,))
gan_output = discriminator(generator(gan_input))
gan = keras.models.Model(gan_input, gan_output)

gan_optimizer = keras.optimizers.RMSprop(lr=0.0004, clipvalue=1.0, decay=1e-8)
gan.compile(optimizer=gan_optimizer, loss='binary_crossentropy')

8.5.6 DCGAN 훈련 방법

이제 훈련을 시작합니다. 훈련 반복의 내용을 요약 정리해 보겠습니다. 매 반복마다 다음을 수행합니다.

  1. 잠재 공간에서 무작위로 포인트를 뽑습니다(랜덤 노이즈).
  2. 이 랜덤 노이즈를 사용해 generator에서 이미지를 생성합니다.
  3. 생성된 이미지와 진짜 이미지를 섞습니다.
  4. 진짜와 가짜가 섞인 이미지와 이에 대응하는 타깃을 사용해 discriminator를 훈련합니다. 타깃은 “진짜”(실제 이미지일 경우) 또는 “가짜”(생성된 이미지일 경우)입니다.
  5. 잠재 공간에서 무작위로 새로운 포인트를 뽑습니다.
  6. 이 랜덤 벡터를 사용해 gan을 훈련합니다. 모든 타깃은 “진짜”로 설정합니다. 판별자가 생성된 이미지를 모두 “진짜 이미지”라고 예측하도록 생성자의 가중치를 업데이트합니다(gan 안에서 판별자는 동결되기 때문에 생성자만 업데이트합니다). 결국 생성자는 판별자를 속이도록 훈련합니다.
import os
from keras.preprocessing import image

# CIFAR10 데이터를 로드합니다
(x_train, y_train), (_, _) = keras.datasets.cifar10.load_data()

# 개구리 이미지를 선택합니다(클래스 6)
x_train = x_train[y_train.flatten() == 6]

# 데이터를 정규화합니다
x_train = x_train.reshape(
    (x_train.shape[0],) + (height, width, channels)).astype('float32') / 255.

iterations = 10000
batch_size = 20
save_dir = './datasets/gan_images/'
if not os.path.exists(save_dir):
    os.mkdir(save_dir)

# 훈련 반복 시작
start = 0
for step in range(iterations):
    # 잠재 공간에서 무작위로 포인트를 샘플링합니다
    random_latent_vectors = np.random.normal(size=(batch_size, latent_dim))

    # 가짜 이미지를 디코딩합니다
    generated_images = generator.predict(random_latent_vectors)

    # 진짜 이미지와 연결합니다
    stop = start + batch_size
    real_images = x_train[start: stop]
    combined_images = np.concatenate([generated_images, real_images])

    # 진짜와 가짜 이미지를 구분하여 레이블을 합칩니다
    labels = np.concatenate([np.ones((batch_size, 1)),
                             np.zeros((batch_size, 1))])
    # 레이블에 랜덤 노이즈를 추가합니다. 아주 중요합니다!
    labels += 0.05 * np.random.random(labels.shape)

    # discriminator를 훈련합니다
    d_loss = discriminator.train_on_batch(combined_images, labels)

    # 잠재 공간에서 무작위로 포인트를 샘플링합니다
    random_latent_vectors = np.random.normal(size=(batch_size, latent_dim))

    # 모두 “진짜 이미지"라고 레이블을 만듭니다
    misleading_targets = np.zeros((batch_size, 1))

    # generator를 훈련합니다(gan 모델에서 discriminator의 가중치는 동결됩니다)
    a_loss = gan.train_on_batch(random_latent_vectors, misleading_targets)

    start += batch_size
    if start > len(x_train) - batch_size:
      start = 0

    # 중간 중간 저장하고 그래프를 그립니다
    if step % 100 == 0:
        # 모델 가중치를 저장합니다
        gan.save_weights('gan.h5')

        # 측정 지표를 출력합니다
        print('스텝 %s에서 판별자 손실: %s' % (step, d_loss))
        print('스텝 %s에서 적대적 손실: %s' % (step, a_loss))

        # 생성된 이미지 하나를 저장합니다
        img = image.array_to_img(generated_images[0] * 255., scale=False)
        img.save(os.path.join(save_dir, 'generated_frog' + str(step) + '.png'))

        # 비교를 위해 진짜 이미지 하나를 저장합니다
        img = image.array_to_img(real_images[0] * 255., scale=False)
        img.save(os.path.join(save_dir, 'real_frog' + str(step) + '.png'))

훈련할 때 적대적 손실이 크게 증가할 수도 있습니다. 반면 판별자의 손실은 0으로 향합니다. 결국 판별자가 생성자를 압도하게됩니다. 이 케이스에는 판별자의 학습률을 낮추고 판별자의 드롭아웃 비율을 높여서 하면됩니다.

8.5.7 정리

생략

8.6 요약

  • 딥러닝을 사용한 창의적인 애플리케이션에서 심층 네트워크는 기존 콘텐츠에 설명을 다는 것을 넘어서 직접 콘텐츠를 생산하기 시작했습니다.
    • 한 번에 하나의 타임스텝씩 시퀀스 데이터를 생성하는 방법, 텍스트 생성이나 음표 하나씩 음악을 생성하거나 어떤 시계열 데이터를 생성하는 곳에 적용할 수 있습니다.
    • 딥드림의 작동 원리, 컨브넷 층 활성화를 최대화를 최대화하기 위해 입력 공간에 경사 상승법을 적용합니다.
    • 스타일 트랜스퍼 적용 방법. 콘텐츠 이미지와 스타일 이미지가 연결되어 재미있는 결과를 만듭니다.
    • GAN과 VAE가 무엇인지와 이를 사용하여 새로운 이미지를 만드는 방법. 잠재 공간의 개념 벡터를 사용하여 이미지를 변형하는 방법
  • 이 분야는 빠르게 성장하고 있으며, 여기서 다룬 기법들은 기초일 뿐입니다. 생성 모델을 위한 딥러닝은 그 자체로 양이 매우 방대합니다.

Leave a Comment