Делаем вариационный автоэнкодер (VAE) в Keras

На предыдущем занятии мы с вами разобрали принцип построения вариационного автоэнкодера. Пришло время его реализовать в пакете Keras. Вначале, как и ранее, подключим необходимые модули и подготовим входную обучающую и тестовую выборки:

import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.datasets import mnist
from tensorflow import keras
import keras.backend as K
from tensorflow.keras.layers import Dense, Flatten, Reshape, Input, Lambda, BatchNormalization, Dropout
 
(x_train, y_train), (x_test, y_test) = mnist.load_data()
 
x_train = x_train / 255
x_test = x_test / 255
 
x_train = np.reshape(x_train, (len(x_train), 28, 28, 1))
x_test  = np.reshape(x_test,  (len(x_test),  28, 28, 1))

Далее, определим размер векторов скрытого пространства, размер пакета (батча) и вспомогательную функцию для применения слоев Dropout и Batch Normalization к нашей НС:

hidden_dim = 2
batch_size = 60 # должно быть кратно 60 000
 
def dropout_and_batch(x):
  return Dropout(0.3)(BatchNormalization()(x))

Я думаю, вы помните, какую роль играют эти слои? Они нужны для уменьшения эффекта переобучения и повышения обобщающих свойств нейронов в сети. В литературе по вариационным автоэнкодерам они часто упоминаются, хотя в каждой конкретной реализации нужно смотреть: нужны они или нет. Я здесь их привел больше в качестве примера. Также обратите внимание, что они определяются по принципу графа:

Это очень удобный инструмент в Keras, особенно когда нужно создать сеть произвольной архитектуры.

Далее, формируем сеть кодера:

input_img = Input((28, 28, 1))
x = Flatten()(input_img)
x = Dense(256, activation='relu')(x)
x = dropout_and_batch(x)
x = Dense(128, activation='relu')(x)
x = dropout_and_batch(x)
 
z_mean = Dense(hidden_dim)(x)
z_log_var = Dense(hidden_dim)(x)

Опять же, используя принцип графов, мы определяем две группы нейронов полносвязного слоя, размерностью hidden_dim (это параметр k из предыдущего занятия). Вектор z_mean – это МО, а вектор z_log_var – логарифм дисперсий (логарифм взят для удобства дальнейших вычислений):

Затем, нам нужно используя величины z_mean и z_log_var сформировать вектор h как нормальную СВ с этими характеристиками. Для этого мы добавим специальный слой

Lambda

который на входе будет брать два тензора:

z_mean, z_log_var: (batch_size, hidden_dim)

и на выходе формировать тензор:

h: (batch_size, hidden_dim)

по общей формуле:

Это делается так. Сначала объявим функцию, которая формирует искомый тензор h:

def noiser(args):
  global z_mean, z_log_var
  z_mean, z_log_var = args
  N = K.random_normal(shape=(batch_size, hidden_dim), mean=0., stddev=1.0)
  return K.exp(z_log_var / 2) * N + z_mean

В этой функции мы используем уже имеющиеся глобальные переменные z_mean, z_log_var и переопределяем их, присваивая конкретные числовые значения (args будет содержать тензоры с числами в процессе работы сети). Затем, мы генерируем тензор нормальных СВ с нулевым МО и единичной дисперсией. На выходе формируем тензор h, умножая случайные величины на тензор СКО и прибавляя тензор МО. Здесь операция

K.exp(z_log_var / 2)

выделяет СКО из логарифма дисперсии:

Теперь у нас все готово для создания Lambda-слоя:

h = Lambda(noiser, output_shape=(hidden_dim,))([z_mean, z_log_var])

На его вход подаются два тензора, на выходе получаем тензор случайных величин h. Обратите внимание, что здесь z_mean, z_log_var пока еще ссылки на слои НС, а не тензоры с числовыми значениями. То есть, мы пока определяем граф нашего VAE.

Далее, определяем слои для декодера. Здесь все достаточно просто и очевидно:

input_dec = Input(shape=(hidden_dim,))
d = Dense(128, activation='relu')(input_dec)
d = dropout_and_batch(d)
d = Dense(256, activation='relu')(input_dec)
d = dropout_and_batch(d)
d = Dense(28*28, activation='sigmoid')(d)
decoded = Reshape((28, 28, 1))(d)

Наконец, используя этот граф, формируем кодер, декодер и вариационный автоэнкодер:

encoder = keras.Model(input_img, h, name='encoder')
decoder = keras.Model(input_dec, decoded, name='decoder')
vae = keras.Model(input_img, decoder(encoder(input_img)), name="vae")

Все, архитектура сети задана. Теперь нам нужно определить критерий качества ее работы. Сделаем это с помощью следующей функции:

def vae_loss(x, y):
  x = K.reshape(x, shape=(batch_size, 28*28))
  y = K.reshape(y, shape=(batch_size, 28*28))
  loss = K.sum(K.square(x-y), axis=-1)
  kl_loss = -0.5 * K.sum(1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)
  return loss + kl_loss

Здесь x – тензор входных данных; y – тензор выходных данных. Для удобства мы преобразовываем их в двумерные тензоры размерностью (batch_size, 28*28). И, затем, вычисляем первый критерий – сумму квадратов ошибок между входным и выходным сигналами. Причем, сумму вычисляем по последней размерности, то есть, по 28*28=784 – пикселам изображений:

Далее, идет вычисление второго критерия – расстояния Кульбака-Лейблера по тензорам z_mean и z_log_var. Чтобы лучше понять как работает эта строчка, положим, что величины z_mean и z_log_var – это векторы длиной k элементов:

И нам нужно получить формулу:

Распишем каждое из слагаемых (применительно к нашему случаю независимых СВ):

Значит, мы можем взять оценки векторов z_mean и z_log_var и записать все в таком виде:

kl_loss = -0.5 * K.sum(1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)

Здесь суммирование будет происходить по длине векторов z_mean и z_log_var, то есть, k раз. В итоге получится следующий вектор:

Суммируем элементы этого вектора, умножаем на -0,5, получаем расстояние Кульбака-Лейблера:

Наконец, в функции вычисления потерь, оба критерия складываются и получается единый показатель качества работы сети.

Далее, мы передаем ссылку на эту функцию при компиляции вариационного автоэнкодера:

vae.compile(optimizer='adam', loss=vae_loss)

и запускаем процесс обучения:

vae.fit(x_train, x_train, epochs=5, batch_size=batch_size, shuffle=True)

Давайте посмотрим на распределение вектора скрытого состояния h:

h = encoder.predict(x_test[:6000], batch_size=batch_size)
plt.scatter(h[:, 0], h[:, 1])

Как видите, распределение получилось близкое к требуемому. Теперь, мы можем брать любые точки из этого пространства и должны при этом получать осмысленные изображения. Проверим это. В квадрате (-3;3) возьмем равномерно точки и подадим на вход декодера:

n = 5
total = 2*n+1
 
plt.figure(figsize=(total, total))
 
num = 1
for i in range(-n, n+1):
  for j in range(-n, n+1):
    ax = plt.subplot(total, total, num)
    num += 1
    img = decoder.predict(np.expand_dims([3*i/n, 3*j/n], axis=0))
    plt.imshow(img.squeeze(), cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)

Увидим следующий набор изображений:

В верхнем левом углу образы получились не очень понятные. Это вполне возможно, так как в точке (-3; -3) имеем самый край области и там могут быть неопределенные изображения. Также видим, что цифры 2, 4, 5, 6 представлены очень плохо. Но это, скорее, ограниченность размера скрытого пространства – всего две величины. Если увеличить его, то можно получить куда более лучшие результаты. И на следующем занятии мы это сделаем.

Видео по теме