Реализация вариационного автокодировщика в Theano. Итоги

Реализация в Theano

Здравствуйте и вновь добро пожаловать на занятия по теме «Глубокое обучение без учителя, часть 2».

В этой лекции мы пройдёмся по реализации вариационного автокодировщика в коде с помощью библиотеки Theano, выполним упражнение по наглядному представлению скрытого пространства, обсудим, откуда берётся функция затрат, и в конце подведем общий итог этой части.

Если вы не хотите попробовать написать код самостоятельно, хотя я настоятельно рекомендую сделать именно так, соответствующий файл в репозитарии курса называется vae_theano.py.

Вначале у нас идёт ряд импортов. Файл util.py – просто локальный файл, помогающий нам загрузить данные. При желании вы можете его проверить, но там лишь стандартный код, который, как я полагаю, на данный момент вы можете уже написать с закрытыми глазами. Далее, как и следовало ожидать, мы импортируем библиотеки Numpy, Matplotlib и Teheano. Обратите внимание, что нам также понадобится объект RandomStreams из Theano, так как именно он используется для представления случайных чисел при построении графа Theano.

from __future__ import print_function, division

from builtins import range, input

# Note: you may need to update your version of future

# sudo pip install -U future

import util

import numpy as np

import theano

import theano.tensor as T

import matplotlib.pyplot as plt

from theano.tensor.shared_randomstreams import RandomStreams

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

class DenseLayer(object):

  def __init__(self, M1, M2, f=T.nnet.relu):

    self.W = theano.shared(np.random.randn(M1, M2) * np.sqrt(2.0 / M1))

    self.b = theano.shared(np.zeros(M2))

    self.f = f

    self.params = [self.W, self.b]

  def forward(self, X):

    return self.f(X.dot(self.W) + self.b)

Затем идёт класс VariationalAutoencoder. Большая часть работы происходит в конструкторе, так что сейчас последует наиболее важная часть программы. Итак, в качестве аргумента он принимает D – входную размерность, и размеры скрытого слоя hidden_layer_sizes. Конечным элементом списка hidden_layer_sizes будет размер выхода кодировщика, одновременно являющийся размером входа декодировщика. Мы будем считать, что размеры слоёв декодировщика являются просто размерами кодировщика, размещённые в обратном порядке.

class VariationalAutoencoder:

  def __init__(self, D, hidden_layer_sizes):

    # hidden_layer_sizes specifies the size of every layer

    # in the encoder

    # up to the final hidden layer Z

    # the decoder will have the reverse shape

После этого мы создаём заполнитель для данных X; он двухмерный, так что это будет матрицей. Затем создаются слои кодировщика, состоящие из объектов DenseLayer, но только до предпоследнего слоя. Это связано с тем, что в слоях мы по умолчанию используем функцию relu, однако для выхода кодировщика нам нужно использовать что-то другое. Не забывайте, что кодировщик должен давать на выходе и среднее значение, и стандартное отклонение гауссового распределения; средние значения не имеют никакой активации, а для стандартных отклонений для активации, как мы видели, используется функция softplus. Впрочем, мы можем определить их вне самих слоёв, что заодно и объясняет, почему выходной размер равен 2*M.

    # represents a batch of training data

    self.X = T.matrix(‘X’)

    # encoder

    self.encoder_layers = []

    M_in = D

    for M_out in hidden_layer_sizes[:-1]:

      h = DenseLayer(M_in, M_out)

      self.encoder_layers.append(h)

      M_in = M_out

    # for convenience, we’ll refer to the final encoder size as M

    # also the input to the decoder size

    M = hidden_layer_sizes[-1]

    # the encoder’s final layer output is unbounded

    # so there is no activation function

    # we also need 2 times as many units as specified by M_out

    # since there needs to be M_out means + M_out variances

    h = DenseLayer(M_in, 2 * M, f=lambda x: x)

    self.encoder_layers.append(h)

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

    # get the mean and variance / std dev of Z.

    # note that the variance must be > 0

    # we can get a sigma (standard dev) > 0 from an unbounded variable by

    # passing it through the softplus function.

    # add a small amount for smoothing.

    current_layer_value = self.X

    for layer in self.encoder_layers:

      current_layer_value = layer.forward(current_layer_value)

    self.means = current_layer_value[:, :M]

    self.stddev = T.nnet.softplus(current_layer_value[:, M:]) + 1e-6

После этого мы делаем выборку из Z при помощи приёма с репараметризацией, виденного вами ранее и позволяющего параметрам кодировщика быть диференцируемыми. Это всё, что касается кодировщика.

Далее идёт декодировщик. Мы создаём объекты DenseLayer для каждого из скрытых слоёв, но в обратном порядке, поскольку нам нужно, чтобы они имели противоположную форму. Разумеется, это значит, что наш выходной слой будет иметь размерность D – входную размерность. Обратите внимание, что функцией активации последнего слоя является сигмоида, поскольку нам нужны вероятности из распределения Бернулли. С логитами, как в Tensorflow, ничего делать не надо.

    # get a sample of Z

    self.rng = RandomStreams()

    eps = self.rng.normal((self.means.shape[0], M))

    self.Z = self.means + self.stddev * eps

    # decoder

    self.decoder_layers = []

    M_in = M

    for M_out in reversed(hidden_layer_sizes[:-1]):

      h = DenseLayer(M_in, M_out)

      self.decoder_layers.append(h)

      M_in = M_out

    # the decoder’s final layer should go through a sigmoid

    h = DenseLayer(M_in, D, f=T.nnet.sigmoid)

    self.decoder_layers.append(h)

Затем мы на самом деле подставляем Z в декодировщик и в конце извлекаем апостериорные прогнозные вероятности.

    # get the posterior predictive

    current_layer_value = self.Z

    for layer in self.decoder_layers:

      current_layer_value = layer.forward(current_layer_value)

    self.posterior_predictive_probs = current_layer_value

Следом мы делаем выборку из этого распределения, что является, как уже говорилось, одной из операций, которую наш автокодировщик должен выполнять.

    # take samples from X_hat

    # we will call this the posterior predictive sample

    self.posterior_predictive = self.rng.binomial(

      size=self.posterior_predictive_probs.shape,

      n=1,

      p=self.posterior_predictive_probs

    )

После этого необходимо написать код для нашей априорной прогнозной выборки. Первый этап – сделать выборку из стандартного нормального распределения размером M – размером скрытого вектора. Затем мы пропускаем эту выборку через декодер, и получаем результат, как уже делали раньше. Отсюда мы можем не только получить выборку, но и вывести на экран среднее значение результата. И то, и другое мы представим наглядно.

    # take sample from a Z ~ N(0, 1)

    # and put it through the decoder

    # we will call this the prior predictive sample

    Z_std = self.rng.normal((1, M))

    current_layer_value = Z_std

    for layer in self.decoder_layers:

      current_layer_value = layer.forward(current_layer_value)

    self.prior_predictive_probs = current_layer_value

    self.prior_predictive = self.rng.binomial(

      size=self.prior_predictive_probs.shape,

      n=1,

      p=self.prior_predictive_probs

    )

И в конце идёт код для нашей третьей операции – получения результата по заданному Z. Нам нужно создать заполнитель для Z; он двухмерный, так что это матрица. После этого мы подаём его на вход декодировщика и получаем выходные средние значения. Вы также можете запросить выборку, но я решил этого не делать.

    # prior predictive from input

    # only used for generating visualization

    Z_input = T.matrix(‘Z_input’)

    current_layer_value = Z_input

    for layer in self.decoder_layers:

      current_layer_value = layer.forward(current_layer_value)

    prior_predictive_probs_from_Z_input = current_layer_value

Затем мы создаём функцию затрат. Напомню, что она состоит из двух частей: KL-расхождения и ожидаемого логарифма правдоподобия. Как и следовало ожидать, это KL-расхождение между Z и стандартным нормальным распределением, определённым нами ранее. Если вы не уверены в том, как найти KL-расхождение между двумя гауссовым распределениями, то ссылки в комментариях должны помочь.

Обратите внимание, что мы суммируем по первой оси. Это связано с тем, что обычно нашим результатом является матрица размерности NxD, так что нам нужно KL-расхождение на выборку. На самом деле результат будет тот же, если бы мы находили KL-расхождение для эквивалентного многомерного гауссового распределения, но поскольку каждая размерность является независимой, суммирование результатов является корректным. Следом идёт ожидаемое значение логарифма правдоподобия, равный, как уже обсуждалось, отрицательному значению кросс-энтропийной функции. И наконец мы вычитаем KL-расхождение из ожидаемого логарифма правдоподобия, чтобы получить ELBO, и здесь мы суммируем по каждой выборке. Однако обратите внимание, что просуммировано было всё, так что с технической точки зрения ранее, когда мы проводили суммирование по первой оси, это на самом деле было необязательно, поскольку при сложении не имеет значения, в каком порядке складывать, – результат будет тот же.

    # now build the cost

    # https://stats.stackexchange.com/questions/7440/kl-divergence-between-two-univariate-gaussians

    # https://stats.stackexchange.com/questions/60680/kl-divergence-between-two-multivariate-gaussians

    kl = -T.log(self.stddev) + 0.5*(self.stddev**2 + self.means**2) – 0.5

    kl = T.sum(kl, axis=1)

    expected_log_likelihood = -T.nnet.binary_crossentropy(

      output=self.posterior_predictive_probs,

      target=self.X,

    )

    expected_log_likelihood = T.sum(expected_log_likelihood, axis=1)

    self.elbo = T.sum(expected_log_likelihood – kl)

    Далее мы должны определить, как выполнять обновления. Чтобы программа в версиях для Theano и Tensorflow была схожей, мы и здесь выполняем алгоритм RMSProp. Итак, первый этап – собрать все параметры из каждого слоя, а затем взять градиенты функции затрат относительно этих параметров. Затем мы определяем некоторые переменные, касающиеся конкретно RMSProp и совпадающие с такими же в Tensorflow, стоящими по умолчанию, в том числе коэффициент затухания, равный 0,9. После этого мы определяем cash и new_cash, содержащее обновлённые значения, а затем действительные кортежи обновлений.

    # define the updates

    params = []

    for layer in self.encoder_layers:

      params += layer.params

    for layer in self.decoder_layers:

      params += layer.params

    grads = T.grad(-self.elbo, params)

    # rmsprop

    decay = 0.9

    learning_rate = 0.001

    # for rmsprop

    cache = [theano.shared(np.ones_like(p.get_value())) for p in params]

    new_cache = [decay*c + (1-decay)*g*g for p, c, g in zip(params, cache, grads)]

    updates = [

        (c, new_c) for c, new_c in zip(cache, new_cache)

    ] + [

        (p, p – learning_rate*g/T.sqrt(new_c + 1e-10)) for p, new_c, g in zip(params, new_cache, grads)

    ]

И наконец нам нужно создать несколько вызываемых функций Theano для написанного кода. Заметьте, что в Theano это несколько удобнее: нам не нужно определять дополнительные методы экземпляра, достаточно лишь напрямую назначить их из функций Theano.

Вначале идёт функция train_op, возвращающая ELBO и выполняющая обновления, далее идёт функция для получения апостериорной прогнозной выборки, затем – функция для априорной прогнозной выборки, потом функция transform, возвращающая среднее значение гауссового распределения кодировщика при некотором заданном X, а затем и функция для получения априорной прогнозной выборки при заданном Z.

    # now define callable functions

    self.train_op = theano.function(

      inputs=[self.X],

      outputs=self.elbo,

      updates=updates

    )

    # returns a sample from p(x_new | X)

    self.posterior_predictive_sample = theano.function(

      inputs=[self.X],

      outputs=self.posterior_predictive,

    )

    # returns a sample from p(x_new | z), z ~ N(0, 1)

    self.prior_predictive_sample_with_probs = theano.function(

      inputs=[],

      outputs=[self.prior_predictive, self.prior_predictive_probs]

    )

    # return mean of q(z | x)

    self.transform = theano.function(

      inputs=[self.X],

      outputs=self.means

    )

    # returns a sample from p(x_new | z), from a given z

    self.prior_predictive_with_input = theano.function(

      inputs=[Z_input],

      outputs=prior_predictive_probs_from_Z_input

    )

И в конце идёт идёт функция fit. К счастью, мы уже сделали большую часть работы, так здесь нам нужно лишь выполнить цикл по количеству эпох, разбить данные на пакеты и вызвать train_op для каждого из пакетов. В конце мы выводим на экран значения функции затрат, чтобы можно было удостовериться, что всё работает как надо.

  def fit(self, X, epochs=30, batch_sz=64):

    costs = []

    n_batches = len(X) // batch_sz

    print(“n_batches:”, n_batches)

    for i in range(epochs):

      print(“epoch:”, i)

      np.random.shuffle(X)

      for j in range(n_batches):

        batch = X[j*batch_sz:(j+1)*batch_sz]

        c, = self.train_op(batch)

        c /= batch_sz # just debugging

        costs.append(c)

        if j % 100 == 0:

          print(“iter: %d, cost: %.3f” % (j, c))

    plt.plot(costs)

    plt.show()

И после этого идёт функция main, использующая всё то, что мы уже написали. Вначале мы получаем данные, причём заметьте, что преобразуем их в действительные двоичные переменные, являющиеся истинными случайными переменными распределения Бернулли. Это не обязательно, но я читал, что так всё работает лучше. Вы можете попробовать оба варианта. Далее идёт цикл, выводящий на экран восстановленные данные из случайно выбранных данных учебного набора. Он написан таким образом, чтобы исходные и восстановленные данные выводились рядом друг с другом, а мы могли просто нажать букву «n», чтобы завершить цикл.

Затем идёт цикл, генерирующий априорные прогнозные выборки. Обратите внимание, что здесь нет входных данных, поскольку Z является лишь выборкой из стандартного нормального распределения; в нём также результаты выводятся рядом друг с другом, так что мы можем увидеть и образец, и среднее значение, из которого этот образец был сгенерирован. В этом цикле также можно нажать «n», чтобы выйти из него.

def main():

  X, Y = util.get_mnist()

  # convert X to binary variable

  X = (X > 0.5).astype(np.float32)

  vae = VariationalAutoencoder(784, [200, 100])

  vae.fit(X)

  # plot reconstruction

  done = False

  while not done:

    i = np.random.choice(len(X))

    x = X[i]

    im = vae.posterior_predictive_sample([x]).reshape(28, 28)

    plt.subplot(1,2,1)

    plt.imshow(x.reshape(28, 28), cmap=’gray’)

    plt.title(“Original”)

    plt.subplot(1,2,2)

    plt.imshow(im, cmap=’gray’)

    plt.title(“Sampled”)

    plt.show()

    ans = input(“Generate another?”)

    if ans and ans[0] in (‘n’ or ‘N’):

      done = True

  # plot output from random samples in latent space

  done = False

  while not done:

    im, probs = vae.prior_predictive_sample_with_probs()

    im = im.reshape(28, 28)

    probs = probs.reshape(28, 28)

    plt.subplot(1,2,1)

    plt.imshow(im, cmap=’gray’)

    plt.title(“Prior predictive sample”)

    plt.subplot(1,2,2)

    plt.imshow(probs, cmap=’gray’)

    plt.title(“Prior predictive probs”)

    plt.show()

    ans = input(“Generate another?”)

    if ans and ans[0] in (‘n’ or ‘N’):

      done = True

if __name__ == ‘__main__’:

  main()

Запустим программу и посмотрим, что у нас получится.

Наглядное представление скрытого пространства

Выполним упражнение по наглядному представлению скрытого пространства, а конкретнее, создадим скрытое пространство с размерностью 2. Это позволит нам создать двухмерную сетку координат с центром в нуле. Эта сетка из z позволит нам увидеть, какое изображение генерируется декодировщиком, а эти изображения будут наглядным представлением того, какая часть скрытого пространства была «изучена» для кодирования. Если вы хотите посмотреть код на Github, то соответствующий файл в репозитарии курса называется visualize_latent_space.py.

Вначале файла идут все обычные импорты. Обратите внимание, что мы можем использовать или реализацию в Theano, или реализацию в Tensorflow, но данная программа будет работать в любом случае. Это потому, что мы создали обе версии с одинаковым API, так что не имеет значения, какую из них использовать.

from __future__ import print_function, division

from builtins import range, input

# Note: you may need to update your version of future

# sudo pip install -U future

import util

import numpy as np

import matplotlib.pyplot as plt

from vae_tf import VariationalAutoencoder

# from vae_theano import VariationalAutoencoder

Далее идёт раздел main. Там мы получаем данные, создаём автокодировщик и приспосабливаем его к данным. Обратите внимание, что размер конечного скрытого слоя равен 2.

if __name__ == ‘__main__’:

  X, Y = util.get_mnist()

  # convert X to binary variable

  X = (X > 0.5).astype(np.float32)

  vae = VariationalAutoencoder(784, [200, 100, 2])

  vae.fit(X)

  Z = vae.transform(X)

  plt.scatter(Z[:,0], Z[:,1], c=Y)

  plt.show()

Обучив автокодировщик, мы можем строить наш график. Мы создадим сетку 20×20 в диапазоне от -3 до +3 вокруг исходного значения. Это также означает, что наше конечное изображение будет 28*20×28*20, поскольку изображения базы MNIST имеют размер 28×28.

  # plot what image is reproduced for different parts of Z

  n = 20 # number of images per side

  x_values = np.linspace(-3, 3, n)

  y_values = np.linspace(-3, 3, n)

  image = np.empty((28 * n, 28 * n))

Далее мы по циклу собираем из сетки все точки данных Z. Это необходимо, чтобы вызвать функцию прогнозирования лишь раз, так как работает она медленно, особенно в Theano. Собрав все Z, мы можем вызвать функцию прогнозирования и получить наши восстановленные X. Справившись с этим, мы входим в ещё один вложенный цикл for для отображения каждого восстановленного изображения на окончательном графике. Напоследок мы выводим на экран изображение.

  # build Z first so we don’t have to keep

  # re-calling the predict function

  # it is particularly slow in theano

  Z = []

  for i, x in enumerate(x_values):

    for j, y in enumerate(y_values):

      z = [x, y]

      Z.append(z)

  X_recon = vae.prior_predictive_with_input(Z)

  k = 0

  for i, x in enumerate(x_values):

    for j, y in enumerate(y_values):

      x_recon = X_recon[k]

      k += 1

      # convert from NxD == 1 x 784 –> 28 x 28

      x_recon = x_recon.reshape(28, 28)

      image[(n – i – 1) * 28:(n – i) * 28, j * 28:(j + 1) * 28] = x_recon

  plt.imshow(image, cmap=’gray’)

  plt.show()

Итак, запустим программу и посмотрим, что получится.

Отметим, что, рассматривая изображение в целом, можно наблюдать границы между каждой из цифр, где одна цифра в некотором роде преобразовывается в другую. Такие границы существуют потому, что есть несколько способов написания цифр, являющиеся неоднозначными. Так, к примеру, единица может выглядеть похожей на семёрку, а тройка – на восьмёрку. Обратите внимание также, что центр размыт. Это связано с тем, что он является чем-то вроде пересечения всех цифр.

Взгляд с байесовской точки зрения

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

Начнём с p(z|x). Мы не знаем, что оно из себя представляет, но хотели бы узнать. Именно это на самом деле наш автокодировщик и делает: он даёт приближённое представление p(z|x) в виде q(z|x). Может возникнуть вопрос: а зачем нам надо знать p(z|x)? p(z|x) называется апостериорной вероятностью, а нашей целью, вообще говоря, является отыскание хорошего отображения x на z.

Чтобы связать эту концепцию с тем, что вы уже знаете, вспомните гауссовы смеси распределений или скрытые марковские модели. В гауссовых смесях распределений p(z|x) указывает нам, к какому кластеру принадлежит x, поскольку z – скрытая переменная – в гауссовых смесях распределений соответствует идентификации кластера. При кластеризации ясно, что наша явная цель следующая: у нас есть точка данных, и мы хотим знать, к какому кластеру она принадлежит.

В случае скрытых марковских моделей, к примеру, если x представляет слова в предложении, z – части речи, а мы хотим сделать разметку по частям речи, то для этого тоже нужно найти z.

Другой способ рассмотрения – с точки зрения классификации. При классификации у нас есть целевые переменные – обозначим их p(y|x), и есть прогнозы нашей нейронной сети – обозначим их q(y|x). Как правило, p(y|x) = 1 для одного конкретного класса и p(y|x) = 0 для всего остального. Однако это по-прежнему допустимое распределение вероятностей, так что обозначение p(y|x) вполне корректно. Как вы знаете, при классификации корректной функцией затрат является кросс-энтропийная функция между p(y|x) и q(y|x). При этом не забывайте, что кросс-энтропийная функция – это то же, что и KL-расхождение, плюс константа. Единственное различие между классификацией (а на самом деле и обучением с учителем вообще) и обучением без учителя состоит в том, что y задано. z же не задано – это нечто вроде ненаблюдаемой целевой переменной. Надеюсь, становится логичным желание отыскать p(z|x) – в обучении без учителя это эквивалент p(y|x).

Сугубо для закрепления этой концепции отметим: в обучении с учителем мы хотим отыскать p(y|x) – истинные целевые переменные, а в обучении без учителя мы хотим точно найти p(z|x) – истинные ненаблюдаемые переменные.

Зная, что нам нужно сделать наше приближение q(z|x) как можно ближе к p(z|x), как это можно сделать? Ну, мы это уже обсуждали. Мы знаем, как измерить близость между двумя распределениями вероятностей – с помощью KL-расхождения. Запишем выражение для KL-расхождения между этими двумя распределениями:

Мы видим, что это интеграл некоторой функции распределения вероятностей. Это же является определением ожидаемого значения:

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

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

Следующий этап – применить теорему Байеса к p(z|x). Не забывайте, что оно равно

Используя это обстоятельство, подставляя его в полученное ранее выражение и вновь используя правила логарифмирования для устранения деления, получим:

Используя это обстоятельство, подставляя его в полученное ранее выражение и вновь используя правила логарифмирования для устранения деления, получим:

Но поскольку ожидание – относительно z, а p(x) не зависит от z, то мы можем вынести его за пределы ожидаемого значения:

Следующий этап – просто алгебраические преобразования. Перенесём log p(x) в левую сторону и поменяем знаки:

Но вот что интересно: мы можем переставить слагаемые в правой части, а именно: оставим log p(x|z), но сложим log q(z|x) и log p(z):

Как видим, на самом деле это другое KL-расхождение, а именно, KL-расхождение между распределением кодировщика q(z|x) и нашей априорной вероятностью по z, то есть p(z). Если помните, эту априорную вероятность мы считаем единичным гауссовым распределением со средним значением 0.

Что же тут интересного? Простительно, если вы уже подзабыли, ведь мы только что много занимались математикой, но правая часть уравнения равна функции ELBO, определённой нами ранее! А поскольку левая и правая части равны, то обе они являются допустимыми выражениями для ELBO. Разумеется, в коде мы используем правую часть, поскольку она состоит из величин, которые мы действительно можем вычислить, тогда как левая часть состоит из величин, ни одну из которых вычислить мы не можем.

Однако левая часть уравнения даёт нам важное обоснование для использования ELBO в качестве целевой функции. Выпишем её отдельно для простоты рассмотрения:

Переставим члены, чтобы KL-расхождение было в правой части:

Тут важно уяснить, что левая часть, p(x), является постоянной относительно q. Другими словами, если мы изменим значение ELBO, то KL-расхождение изменится таким образом, чтобы левая часть оставалась постоянной. Иначе говоря, максимизируя ELBO, мы в то же время минимизируем KL-расхождение между q и истинной апостериорной вероятностью, которое и является, как отмечалось ранее, нашей первоначальной целью.

Это же объясняет, почему наша функция затрат называется ELBO. Напомню, что p(x) называется доказательством, а мы уже знаем, что доказательство состоит из ELBO и KL-расхождения. Так как KL-расхождение должно быть больше или равным нулю, то и логарифм p(x) всегда должен быть больше или равным ELBO. А поскольку левая часть представляет собой доказательство, то ELBO представляет собой нижнюю границу доказательства, отсюда и название – evidence lower bound, нижняя граница доказательства.

Подведём итоги лекции, ведь она была довольно длинной.

Изначально я лишь дал вам целевую функцию и заявил, что её нужно минимизировать. Мы рассмотрели её с практической точки зрения, чтобы убедиться, что она имеет смысл. Целевая функция состоит из двух частей: ошибки восстановления и члена регуляризации; мы назвали функцию ELBO. Мы также связали это с понятиями глубокого обучения и машинного обучения в целом, отметив, что в ряде случаев частой исходной предпосылкой является утверждение, что целевая функция есть объединением целевой исходящей ошибки и члена регуляризации. Таким образом, в этом смысле все наши действия полностью логичны.

После этого мы рассмотрели целевую функцию с вероятностной точки зрения, отметив, что на самом деле нам нужно приближённо представить апостериорную вероятность p(z|x). Затем мы показали, использовав алгебраические преобразования и правила теории вероятности, что нахождение максимума функции ELBO эквивалентно нахождению минимума KL-расхождения между нашим приближённым отображением q(z|x) и истинной p(z|x).

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

Раздел вариационных автокодировщиков. Итоги

Наконец подведём итог всему тому, что рассмотрели в этой части.

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

Мы видели, что большим отличием между вариационными и обычными автокодировщиками является то, что поскольку вариационные автокодировщики являются байесовскими, то представление в каждом интересующем на слое является распределением, так что на выходе кодировщика мы получаем гауссово распределение, тогда как на входе и на выходе – распределения Бернулли. Мы узнали, что способ выполнения операции передачи данных несколько отличается от привычных нам: получив результат на выходе кодировщика, мы делаем выборку из гауссового распределения, а затем подставляем её в декодировщик. В библиотеке Tensorflow эта операция производится с помощью модуля Stochastic Tensor, а в библиотеке Theano – с помощью репараметризации. Лично мне приём с репараметризацией нравится больше, поскольку он имеет более широкое применение.

Затем мы рассмотрели функцию затрат вариационного автокодировщика. Мы начали с простого её рассмотрения и убедились, что она имеет смысл. Мы видели, что она является объединением двух членов: ожидаемого значения логарифма правдоподобия и KL-расхождения:

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

После этого мы рассмотрели функцию затрат с вероятностной точки зрения. Мы начали с предпосылки, что нам нужно сделать как можно более точное приближение апостериорной вероятности p(z|x), что логично, поскольку в обучении с учителем мы делаем то же самое, разве что обозначаем выход через y и имеем заданное p(y|x), а стараемся найти хорошее q(y|x), которое бы поточнее приближённо отображала p(y|x). Отличие в случае обучения без учителя заключается в том, что p(z|x) не задано, но мы по-прежнему желаем найти q(z|x) для его как можно более точного отображения.

С этой целью мы применили кое-какую хитрую математику и вывели пару эквивалентных выражений для так называемой функции ELBO:

Эти два выражения для ELBO помогают нам по-разному. Во-первых, это лишь функция затрат, которую мы рассматривали ранее и которую можем вычислить в коде и оптимизировать, как и любую другую функцию затрат. Во-вторых, это показало нам, почему функция ELBO имеет такое название – она расшифровывается как «нижняя граница доказательства» и по мере своего увеличения уменьшает KL-расхождение между p(z|x) и q(z|x), что и является нашей исходной целью.