Generative adversarial networks (GAN) пользуются все большей популярностью. Многие говорят о них, кто-то даже уже использует… но, как выясняется, пока мало кто (даже из тех кто пользуется) понимает и может объяснить. ;-)
Давайте разберем на самом простом примере, как же они работают, чему учатся и что на самом деле порождают.
Для начала рекомендую всем прочитать отличную статью "Фальшивомонетчики против банкиров: стравливаем adversarial networks в Theano". Тем более, что пример мы возьмем как раз из нее.
Кому-то эта статья может показаться многословной, а объяснения избыточными и даже повторяющимися. Впрочем, так оно и есть. Однако не забываем, что повторение — мать учения. Кроме того, разные люди очень по-разному воспринимают текст, так что порой даже небольшие изменения в формулировках играют большую роль. В общем, все, что уже понятно, просто пролистывайте.
Итак, у нас есть две нейронных сети. Первая сеть, порождающая, обозначим ее как функцию yG=G(z), на вход она принимает некое значение z, а на выходе выдает значение yG. Вторая сеть, различающая, и мы ее обозначим как функцию yD=D(х), то есть на входе — x, на выходе — yD.
Порождающая сеть должна научиться генерировать такие образцы yG, что различающая сеть D не сможет их отличить от неких настоящих, эталонных образцов. А различающая сеть, в свою очередь, должна, наоборот, научиться отличать эти сгенерированные образцы от настоящих.
Изначально сети не знают ничего, поэтому их надо обучить. Но как? Ведь для обучения обычных сетей нужен специальный набор обучающих данных — (Xi, Yi). На вход сети последовательно подаются Xi и рассчитываются yi=N(Xi). Затем по разнице между Yi (реальное значение) и yi (результат работы сети) перерассчитываются коэффициенты сети и так по кругу.
Так, да не так. На самом деле на каждом шаге обучения вычисляется значение функции потерь (loss function), по градиенту которой потом пересчитываются коэффициенты сети. А вот функция потерь уже тем или иным образом учитывает отличие Yi от yi. Но, строго говоря, это совсем не обязательно. Во-первых, функция потерь может быть устроена и совершенно иным образом, например, учитывать значения коэффициентов сети или значение какой-нибудь другой функции. Во-вторых, вместо минимизации функции потерь в ходе обучения сети можно решать другую оптимизационную задачу, например, максимизации функции успеха. И это как раз используется в GAN.
Обучение первой, порождающей, сети заключается в максимизации функционала D(G(z)). То есть эта сеть стремится максимизировать не свой результат, а результат работы второй, различающей, сети. Проще говоря, порождающая сеть должна научиться для любого значения, поданного на ее вход, сгенерировать на выходе такое значение, подав которое на вход различающей сети, получим максимальное значение на ее выходе (возможно, это стоит прочитать два раза).
Если на выходе различающей сети стоит сигмоида, то можно говорить, что она возвращает вероятность того, что на вход сети подано «правильное» значение. Таким образом, порождающая сеть стремится максимизировать вероятность того, что различающая сеть не отличит результат работы порождающей сети от «эталонных» образцов (а это означает, что порождающая сеть порождает правильные образцы).
Тут важно запомнить, что для того чтобы сделать один шаг в обучении порождающей сети необходимо рассчитать не только результат работы порождающей сети, но и результат работы различающей сети. Сформулирую хоть и коряво, но интуитивно понятно: порождающая сеть будет учиться по градиенту результата работы различающей сети.
Обучение второй, различающей, сети заключается в максимизации функционала D(x)(1 — D(G(z))). Огрубляя, она (сеть, то есть функция D()) должна выдавать «единички» для эталонных образцов и «нолики» для образцов, сгенерированных порождающей сетью.
Причем сеть ничего не знает о том, что ей подано на вход: эталон или подделка. Об этом «знает» только функционал. Однако сеть учится в сторону градиента этого функционала, который «как бы» прозрачно намекает ей, насколько она хороша.
Обратите внимание, на каждом шаге обучения различающей сети один раз рассчитывается результат работы порождающей сети и два раза — результат работы различающей сети: в первый раз ей на вход подается эталонный образец, а во второй — результат порождающей сети.
В общем, обе сети связаны в неразрывный круг взаимного обучения. И чтобы вся эта конструкция работала нам нужны эталонные образцы — набор обучающих данных (Xi). Заметьте, что Yi здесь не нужны. Хотя, понятно, что на самом деле по умолчанию подразумевается, что каждому Xi соответствует Yi=1.
В процессе обучения на вход порождающей сети на каждом шаге подаются какие-то значения, например, совершенно случайные числа (или совсем даже неслучайные числа — рецепты для управляемой генерации). А на вход различающей сети на каждом шаге подается очередной эталонный образец и очередной результат работы порождающей сети.
В результате порождающая сеть должна научиться генерировать образцы как можно более близкие к эталонным. А различающая сеть должна научиться отличать эталонные образцы от порожденных.
Хотя стоит помнить, что порожденные образцы могут получиться и неудачными, ведь целью обучения порождающей сети является максимизация функционала похожести. И этот достигнутый максимум может быть лишь немногим больше нуля, то есть сеть G так ничему толком не научилась. Так что не забудьте проверить и убедиться, что все хорошо.
Теперь заглянем в код, поскольку там тоже не все так просто.
Сначала импортируем все нужные модули:
Определим функцию, возвращающую равномерный шум на отрезке [-5,5], который далее будем подавать на вход порождающей сети.
Создадим символьную переменную, которая будет являться входом порождающей сети:
И опишем саму сеть:
В терминах библиотеки lasagne в данной сети 4 слоя. Но, с академической точки зрения, входной и выходной слой не считаются, поэтому получаем двухслойную сеть.
В переменную G_out будет записан результат работы сети, после того как на ее вход (в G_input) будет подано какое-нибудь значение. Впоследствии G_out будет передана на вход различающей сети, поэтому по своему формату G_out и D_input должны совпадать.
Теперь создадим символьную переменную, которая будет входом различающей сети и в которую будем подавать «эталонные» образцы.
И опишем различающую сеть. В данном случае она почти ничем не отличается от порождающей, только на выходе у нее сигмоида.
А сейчас сделаем хитрый финт. Как вы помните, на вход различающей сети надо подавать то эталонные образцы, то результат работы порождающей сети. Но в вычислительном графе (проще говоря, в Theano, TensorFlow и им подобных библиотеках) это сделать невозможно. Поэтому мы создадим третью сеть, которая станет полной копией ранее описанной различающей сети.
И здесь на вход сети подается значение G_out, которое является результатом работы порождающей сети. Причем коэффициенты всех слоев третьей сети равны коэффициентам второй сети. Поэтому третья и вторая сеть являются полными копиями друг друга.
Тем не менее, результаты работы этих двух одинаковых сетей будут выводится в разные переменные.
Вот мы и добрались до задания оптимизационных функционалов:
Теперь вы видите, зачем были нужны две выходные переменные различающей сети.
Далее создаем функцию обучения порождающей сети:
В G_params будет список всех коэффициентов всех слоев порождающей сети.
В G_lr будет храниться скорость обучения.
G_updates — собственно, функция обновления коэффициентов методом градиентного спуска. Обратите внимание, первым параметром она принимает все-таки функцию потерь, то есть она не максимизирует G_obj, а минимизирует (1-G_obj) (но это всего лишь особенность Theano). Вторым параметром ей передаются все коэффициенты сети, а затем скорость обучения и константа со значением импульса (которое нужно только потому что в качестве метода градиентного спуска выбран метод импульса Нестерова).
В результате в G_train мы получим функцию обучения сети, на вход которой подается G_input, а результатом расчета является G_obj, то есть оптимизационный функционал для порождающей сети.
Теперь все то же самое для различающей сети:
Заметьте, что D_train уже является функцией от двух переменных G_input (вход порождающей сети) и D1_input (эталонные образцы).
Наконец-то запускаем обучение. В цикле по эпохам:
Сначала обучаем различающую сеть, причем не однократно, а K раз.
х — эталонные образцы (в данном случае, числа из нормального распределения с параметрами mu и sigma):
z — случайный шум
Далее запускается магия Theano:
— эталонные образцы подаются на вход различающей сети
— случайный шум z подается на вход порождающей сети, а ее результат на вход различающей сети
— после чего рассчитывается оптимизационный функционал для различающей сети.
Результат функции D_train, а это, как вы помните, оптимизационный функционал D_obj, сам по себе нам не нужен, однако он в явном виде используется для обучения данной сети пусть и несколько незаметным образом.
Затем обучаем порождающую сеть: снова формируем вектор случайных значений и генерируем результат, который впрочем на этапе обучения тоже используется только в процессе расчета оптимизационного функционала.
По идее, исходя из оригинального описания задачи, обе сети должны принимать на вход 1 вещественное значение, но для ускорения обучения мы сразу подаем вектор из М значений, то есть «как бы» выполняются М итераций работы сети.
Через каждые 10 эпох немного убавляем скорость обучения обеих сетей.
В конце концов обучение завершается, и можно использовать сеть G для генерации образцов, подавая ей на вход случайные данные или очень даже неслучайные данные (рецепты), которые позволят порождать образцы с определенными свойствами.
Давайте разберем на самом простом примере, как же они работают, чему учатся и что на самом деле порождают.
Для начала рекомендую всем прочитать отличную статью "Фальшивомонетчики против банкиров: стравливаем adversarial networks в Theano". Тем более, что пример мы возьмем как раз из нее.
Кому-то эта статья может показаться многословной, а объяснения избыточными и даже повторяющимися. Впрочем, так оно и есть. Однако не забываем, что повторение — мать учения. Кроме того, разные люди очень по-разному воспринимают текст, так что порой даже небольшие изменения в формулировках играют большую роль. В общем, все, что уже понятно, просто пролистывайте.
Соперничающие сети
Итак, у нас есть две нейронных сети. Первая сеть, порождающая, обозначим ее как функцию yG=G(z), на вход она принимает некое значение z, а на выходе выдает значение yG. Вторая сеть, различающая, и мы ее обозначим как функцию yD=D(х), то есть на входе — x, на выходе — yD.
Порождающая сеть должна научиться генерировать такие образцы yG, что различающая сеть D не сможет их отличить от неких настоящих, эталонных образцов. А различающая сеть, в свою очередь, должна, наоборот, научиться отличать эти сгенерированные образцы от настоящих.
Изначально сети не знают ничего, поэтому их надо обучить. Но как? Ведь для обучения обычных сетей нужен специальный набор обучающих данных — (Xi, Yi). На вход сети последовательно подаются Xi и рассчитываются yi=N(Xi). Затем по разнице между Yi (реальное значение) и yi (результат работы сети) перерассчитываются коэффициенты сети и так по кругу.
Так, да не так. На самом деле на каждом шаге обучения вычисляется значение функции потерь (loss function), по градиенту которой потом пересчитываются коэффициенты сети. А вот функция потерь уже тем или иным образом учитывает отличие Yi от yi. Но, строго говоря, это совсем не обязательно. Во-первых, функция потерь может быть устроена и совершенно иным образом, например, учитывать значения коэффициентов сети или значение какой-нибудь другой функции. Во-вторых, вместо минимизации функции потерь в ходе обучения сети можно решать другую оптимизационную задачу, например, максимизации функции успеха. И это как раз используется в GAN.
Обучение первой, порождающей, сети заключается в максимизации функционала D(G(z)). То есть эта сеть стремится максимизировать не свой результат, а результат работы второй, различающей, сети. Проще говоря, порождающая сеть должна научиться для любого значения, поданного на ее вход, сгенерировать на выходе такое значение, подав которое на вход различающей сети, получим максимальное значение на ее выходе (возможно, это стоит прочитать два раза).
Если на выходе различающей сети стоит сигмоида, то можно говорить, что она возвращает вероятность того, что на вход сети подано «правильное» значение. Таким образом, порождающая сеть стремится максимизировать вероятность того, что различающая сеть не отличит результат работы порождающей сети от «эталонных» образцов (а это означает, что порождающая сеть порождает правильные образцы).
Тут важно запомнить, что для того чтобы сделать один шаг в обучении порождающей сети необходимо рассчитать не только результат работы порождающей сети, но и результат работы различающей сети. Сформулирую хоть и коряво, но интуитивно понятно: порождающая сеть будет учиться по градиенту результата работы различающей сети.
Обучение второй, различающей, сети заключается в максимизации функционала D(x)(1 — D(G(z))). Огрубляя, она (сеть, то есть функция D()) должна выдавать «единички» для эталонных образцов и «нолики» для образцов, сгенерированных порождающей сетью.
Причем сеть ничего не знает о том, что ей подано на вход: эталон или подделка. Об этом «знает» только функционал. Однако сеть учится в сторону градиента этого функционала, который «как бы» прозрачно намекает ей, насколько она хороша.
Обратите внимание, на каждом шаге обучения различающей сети один раз рассчитывается результат работы порождающей сети и два раза — результат работы различающей сети: в первый раз ей на вход подается эталонный образец, а во второй — результат порождающей сети.
В общем, обе сети связаны в неразрывный круг взаимного обучения. И чтобы вся эта конструкция работала нам нужны эталонные образцы — набор обучающих данных (Xi). Заметьте, что Yi здесь не нужны. Хотя, понятно, что на самом деле по умолчанию подразумевается, что каждому Xi соответствует Yi=1.
В процессе обучения на вход порождающей сети на каждом шаге подаются какие-то значения, например, совершенно случайные числа (или совсем даже неслучайные числа — рецепты для управляемой генерации). А на вход различающей сети на каждом шаге подается очередной эталонный образец и очередной результат работы порождающей сети.
В результате порождающая сеть должна научиться генерировать образцы как можно более близкие к эталонным. А различающая сеть должна научиться отличать эталонные образцы от порожденных.
Хотя стоит помнить, что порожденные образцы могут получиться и неудачными, ведь целью обучения порождающей сети является максимизация функционала похожести. И этот достигнутый максимум может быть лишь немногим больше нуля, то есть сеть G так ничему толком не научилась. Так что не забудьте проверить и убедиться, что все хорошо.
Разбираем код
Теперь заглянем в код, поскольку там тоже не все так просто.
Сначала импортируем все нужные модули:
import numpy as np
import lasagne
import theano
import theano.tensor as T
from lasagne.nonlinearities import rectify, sigmoid, linear, tanh
Определим функцию, возвращающую равномерный шум на отрезке [-5,5], который далее будем подавать на вход порождающей сети.
def sample_noise(M):
return np.float32(np.linspace(-5.0, 5.0, M) + np.random.random(M) * 0.01).reshape(M,1)
Создадим символьную переменную, которая будет являться входом порождающей сети:
G_input = T.matrix('Gx')
И опишем саму сеть:
G_l1 = lasagne.layers.InputLayer((None, 1), G_input)
G_l2 = lasagne.layers.DenseLayer(G_l1, 10, nonlinearity=rectify)
G_l3 = lasagne.layers.DenseLayer(G_l2, 10, nonlinearity=rectify)
G_l4 = lasagne.layers.DenseLayer(G_l3, 1, nonlinearity=linear)
G = G_l4
G_out = lasagne.layers.get_output(G)
В терминах библиотеки lasagne в данной сети 4 слоя. Но, с академической точки зрения, входной и выходной слой не считаются, поэтому получаем двухслойную сеть.
В переменную G_out будет записан результат работы сети, после того как на ее вход (в G_input) будет подано какое-нибудь значение. Впоследствии G_out будет передана на вход различающей сети, поэтому по своему формату G_out и D_input должны совпадать.
Теперь создадим символьную переменную, которая будет входом различающей сети и в которую будем подавать «эталонные» образцы.
D1_input = T.matrix('D1x')
И опишем различающую сеть. В данном случае она почти ничем не отличается от порождающей, только на выходе у нее сигмоида.
D1_target = T.matrix('D1y')
D1_l1 = lasagne.layers.InputLayer((None, 1), D1_input)
D1_l2 = lasagne.layers.DenseLayer(D1_l1, 10, nonlinearity=tanh)
D1_l3 = lasagne.layers.DenseLayer(D1_l2, 10, nonlinearity=tanh)
D1_l4 = lasagne.layers.DenseLayer(D1_l3, 1, nonlinearity=sigmoid)
D1 = D1_l4
А сейчас сделаем хитрый финт. Как вы помните, на вход различающей сети надо подавать то эталонные образцы, то результат работы порождающей сети. Но в вычислительном графе (проще говоря, в Theano, TensorFlow и им подобных библиотеках) это сделать невозможно. Поэтому мы создадим третью сеть, которая станет полной копией ранее описанной различающей сети.
D2_l1 = lasagne.layers.InputLayer((None, 1), G_out)
D2_l2 = lasagne.layers.DenseLayer(D2_l1, 10, nonlinearity=tanh, W=D1_l2.W, b=D1_l2.b)
D2_l3 = lasagne.layers.DenseLayer(D2_l2, 10, nonlinearity=tanh, W=D1_l3.W, b=D1_l3.b)
D2_l4 = lasagne.layers.DenseLayer(D2_l3, 1, nonlinearity=sigmoid, W=D1_l4.W, b=D1_l4.b)
D2 = D2_l4
И здесь на вход сети подается значение G_out, которое является результатом работы порождающей сети. Причем коэффициенты всех слоев третьей сети равны коэффициентам второй сети. Поэтому третья и вторая сеть являются полными копиями друг друга.
Тем не менее, результаты работы этих двух одинаковых сетей будут выводится в разные переменные.
D1_out = lasagne.layers.get_output(D1)
D2_out = lasagne.layers.get_output(D2)
Вот мы и добрались до задания оптимизационных функционалов:
G_obj = (T.log(D2_out)).mean()
D_obj = (T.log(D1_out) + T.log(1 - D2_out)).mean()
Теперь вы видите, зачем были нужны две выходные переменные различающей сети.
Далее создаем функцию обучения порождающей сети:
G_params = lasagne.layers.get_all_params(G, trainable=True)
G_lr = theano.shared(np.array(0.01, dtype=theano.config.floatX))
G_updates = lasagne.updates.nesterov_momentum(1 - G_obj, G_params, learning_rate=G_lr, momentum=0.6)
G_train = theano.function([G_input], G_obj, updates=G_updates)
В G_params будет список всех коэффициентов всех слоев порождающей сети.
В G_lr будет храниться скорость обучения.
G_updates — собственно, функция обновления коэффициентов методом градиентного спуска. Обратите внимание, первым параметром она принимает все-таки функцию потерь, то есть она не максимизирует G_obj, а минимизирует (1-G_obj) (но это всего лишь особенность Theano). Вторым параметром ей передаются все коэффициенты сети, а затем скорость обучения и константа со значением импульса (которое нужно только потому что в качестве метода градиентного спуска выбран метод импульса Нестерова).
В результате в G_train мы получим функцию обучения сети, на вход которой подается G_input, а результатом расчета является G_obj, то есть оптимизационный функционал для порождающей сети.
Теперь все то же самое для различающей сети:
D_params = lasagne.layers.get_all_params(D1, trainable=True)
D_lr = theano.shared(np.array(0.1, dtype=theano.config.floatX))
D_updates = lasagne.updates.nesterov_momentum(1 - D_obj, D_params, learning_rate=D_lr, momentum=0.6)
D_train = theano.function([G_input, D1_input], D_obj, updates=D_updates)
Заметьте, что D_train уже является функцией от двух переменных G_input (вход порождающей сети) и D1_input (эталонные образцы).
Наконец-то запускаем обучение. В цикле по эпохам:
for i in range(epochs):
Сначала обучаем различающую сеть, причем не однократно, а K раз.
for j in range(K):
х — эталонные образцы (в данном случае, числа из нормального распределения с параметрами mu и sigma):
z — случайный шум
x = np.float32(np.random.normal(mu, sigma, (M,1)))
z = sample_noise(M)
Далее запускается магия Theano:
— эталонные образцы подаются на вход различающей сети
— случайный шум z подается на вход порождающей сети, а ее результат на вход различающей сети
— после чего рассчитывается оптимизационный функционал для различающей сети.
D_train(z, x)
Результат функции D_train, а это, как вы помните, оптимизационный функционал D_obj, сам по себе нам не нужен, однако он в явном виде используется для обучения данной сети пусть и несколько незаметным образом.
Затем обучаем порождающую сеть: снова формируем вектор случайных значений и генерируем результат, который впрочем на этапе обучения тоже используется только в процессе расчета оптимизационного функционала.
z = sample_noise(M)
G_train(z)
По идее, исходя из оригинального описания задачи, обе сети должны принимать на вход 1 вещественное значение, но для ускорения обучения мы сразу подаем вектор из М значений, то есть «как бы» выполняются М итераций работы сети.
Через каждые 10 эпох немного убавляем скорость обучения обеих сетей.
if i % 10 == 0:
G_lr *= 0.999
D_lr *= 0.999
В конце концов обучение завершается, и можно использовать сеть G для генерации образцов, подавая ей на вход случайные данные или очень даже неслучайные данные (рецепты), которые позволят порождать образцы с определенными свойствами.