Generative Adversarial Networks (Генеративные Состязательные Сети) придуманы сравнительно недавно в 2014 г. и были представлены Ian’a Goodfellow’a и рядом других исследователей университета Монреаля, включая Yoshua Bengio.
Идея генеративно-состязательных сетей гениально проста. Есть две нейронных сети работающих в паре:
- Творец (generator) — формирует новые объекты заданного класса при подаче на вход шума. Например, фото лиц людей.
- Критик (discriminator) — оценивает качество работ творца и дает ему обратную связь: хорошо или никуда не годится.
Мне больше нравится именно термин творец и критик, хотя я не видел, чтобы эти сети в статьях так называли. Слово дискриминатор для меня носит какой-то негативный оттенок. 🙂
Критика обучают на реальных выборках, размечая при обучении, что на вход поданы реальные (тренировочные) данные. Например, в жизни искусствовед (критик картин) обучается на просмотре картин художников, пытаясь отличить мельчайшие нюансы в манере их работы. Очень тонкие детали, недоступные глазу простого обывателя.
Творец поначалу работает в паре с учителем. Учитель одновременно выступает и в роли критика, а точнее — мотиватора. Он обращает внимание ученика на какие-то нюансы его работы, чтобы улучшать его мастерство. При этом делать это он должен очень осторожно, чтобы не демотивировать ученика, иначе у него может пропасть интерес к живописи. У него очень важная задача — акцентировать внимание ученика на удачных моментах его работ.
Творец-ученик должен быть в гармонии с критиком-учителем. Если ученик превосходит своего учителя, то ему нужно искать нового учителя, иначе рост мастерства может замедлится. В жизни так и происходит. Например, когда изучаешь язык сначала прогресс идет достаточно хорошо, а затем наступает фаза насыщения. Общаться можешь уже хорошо, а учить идиомы уже слишком трудозатратно, поскольку не так много людей смогут оценить по-достоинству. 🙂 В жизни, ученик является и критиком самому себе — самокритик. В генеративно-состязательных нейронных сетях (GAN-ах) эти роли разведены.
Как работает GAN
Одна нейронная сеть работает в роли творца (генератора) и создает новые экземпляры данных (генерирует новый датаcет). Вторая — критик (дискриминатор) оценивает их качество/подлинность и решает, относится ли полученный экземпляр данных к тренировочному датасету (реальному) или создан творцом.
Например, творец занимается калиграфией и генерирует рукописные цифры, подобные тем, что имеются в наборе данных MNIST. Нарисованные цифры смешиваются с цифрами из набора MNIST и цель критика-дискриминатора — отличить экземпляры из набора от работ творца.
Между тем, творец-генератор постоянно создает новые изображения, которые отдает критику-дискриминатору. Он надеется, что в какой-то момент времени его мастерство настолько вырастет, что критик перестанет угадывать и начнет ошибаться, принимая его работы за подлинные. Идет постоянное состязание между творцом и искусствоведом. 🙂
Шаги, которые проходит GAN:
- Генератор получает шум навходе и пропуская его через веса сети формирует изображение.
- Это сгенерированное изображение подается в дискриминатор вместе с потоком изображений, взятых из набора данных.
- Дискриминатор принимает как реальные, так и поддельные изображения и возвращает вероятности, числа от 0 до 1, причем 1 говорит о том, что изображение точно из тренировочного датасета, 0 — сформированное генератором.
Таким образом, у GAN есть двойной цикл обратной связи:
- Дискриминатор находится в цикле с изображениями из исходного датасета.
- Генератор находится в цикле с дискриминатором, получая от него обратную связь, насколько удачно он создал новый экземпляр датасета.
Код GAN на Keras
Пример кода взят отсюда. Я несколько модифицировал его для удобства понимания и гибкости. Google Colab для изучения GAN — здесь. Там достаточно длинный класс, который полностью разбирать в тексте не имеет особого смысла.
Код снабжен большим количеством комментариев взятых по аналогии из курса «Нейронные сети на Python» читаемого Дмитрием Романовым в «Университете искуственного интеллекта«. Действительно значимых для понимания кода комментариев немного, я на них акцентирую внимания отдельно.
Метод генератора в классе GAN-а прост:
def build_generator(self): #Метод для создания генератора noise_shape = (self.latent_dim,) model = Sequential() # Инициализируем модель generator model.add(Dense(256, input_shape=noise_shape, name = "Generator_In")) # Добавляем Dense-слой на 256 нейронов (размерность входных данных = latent_dim) model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(BatchNormalization(momentum=0.8)) # Добавляем слой BatchNormalization (momentum - параметр расчета скользящего среднего и дисперсии) model.add(Dense(512)) # Добавляем Dense-слой на 512 нейронов model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(BatchNormalization(momentum=0.8)) # Добавляем слой BatchNormalization (momentum - параметр расчета скользящего среднего и дисперсии) model.add(Dense(1024)) # Добавляем Dense-слой на 1024 нейронов model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(BatchNormalization(momentum=0.8)) # Добавляем слой BatchNormalization (momentum - параметр расчета скользящего среднего и дисперсии) model.add(Dense(np.prod(self.img_shape), activation='tanh')) model.add(Reshape(self.img_shape, name = "Generator_Out")) model.name = "Generator" model.summary() noise = Input(shape=noise_shape) # Создаем слой Input (Записываем входные данные рамерностью latent_dim в noise) img = model(noise) # Записываем в переменную img значение, возвращаемое generator'ом с входным параметром noise return Model(inputs = noise, outputs = img) # Возвращаем модель generator-а (входные данные: noise, выходные данные: img)
На вход модели подается вектор шума, проходит через несколько полносвязных слоев и далее Reshape(self.img_shape) преобразует в изображение, которое будет подаваться на вход критика-дискриминатора.
Критик-дискриминатор — это нейронная сеть построенная для классификации. Ей нужно отличать оригинальное изображение из тестового датасета от сформированного творцом-генератором:
def build_discriminator(self, img_shape): # Функция создания дискриминатора #img_shape = (self.img_rows, self.img_cols, self.channels) model = Sequential() # Инициализируем модель discriminator model.add(Flatten(input_shape=img_shape, name = "Discriminator_In")) # Создаем слой Flatten (размерность входных данных = (img_rows, img_cols, channels), размерность выходных данных = img_rows * img_cols * channels ) model.add(Dense(512)) # Добавляем Dense-слой на 512 нейронов model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(Dense(256)) # Добавляем Dense-слой на 256 нейронов model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(Dense(1, activation='sigmoid', name = "Discriminator_Out")) # Добавляем Dense-слой c 1 нейроном с активационной функцией sigmoid, поскольку нам нужно категорировать входноые изображения на два класса 1 - из тестовой выборки и 0 - сформирован генератором. model.name = "Discriminator" model.summary() img = Input(shape=img_shape) # Создаем слой Input (записываем входные данные размерностью (img_rows, img_cols, channels) в img) validity = model(img) # Записываем в переменную validity значение, возвращаемое discriminator'ом с входным параметром img return Model(inputs = img, outputs = validity) # Создаем модель discriminator (входные данные: img, выходные данные: validity)
В аргументе передается размер матрицы изображения. Он вынесен, поскольку при создании объекта класса в качестве аргумента можно передать другой дискриминатор (функцию) и ему для создания первого слоя потребуется shape изображения. Это учебный код, для демонстрации как работает GAN.
Поскольку сеть построена на полносвязных слоях, принимающих вектор, сначала двумерное избражение преобразуется в вектор Flatten(input_shape=img_shape). Затем проходит через несколько полносвязных слоев и на выходе слой с одним нейроном и активационной функцией sigmoid, который выдает степень уверенности дискриминатора: 1 — изображение из исходного (тренировочного) dataset-а, 0 — изображение созданное генератором.
Пример сверточного дискриминатора. Он принимает на вход размер изображения. Эта функция создается снаружи класса и может быть передана при создании объекта класса как callback функция.
def build_conv_discriminator(img_shape): # Функция создания сверточного дискрминатора model = Sequential() # Инициализируем модель currDisc model.add(Conv2D(4, (3,3), padding="same", input_shape=img_shape, name = "Discriminator_In")) # Создаем слой Conv2D (размерность входных данных (img_shape), ядро свертки = 4, окно свертки = (3,3)) model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(Flatten()) # Добавляем слой Flatten () model.add(Dense(512)) # Добавляем Dense-слой на 512 нейронов model.add(LeakyReLU(alpha=0.2)) # Добавляем слой активационной функции с параметром 0.2 model.add(Dense(1, activation='sigmoid', name = "Discriminator_Out")) # Добавляем Dense-слой c 1 нейроном с активационной функцией sigmoid, поскольку нам нужно категорировать входноые изображения на два класса 1 - из тестовой выборки и 0 - сформирован генератором. img = Input(shape=img_shape) # Создаем слой Input (записываем входные данные размерностью (img_rows, img_cols, channels) в img) validity = model(img) # Записываем в переменную validity значение, возвращаемое currDisc'ом с входным параметром img discriminator_conv = Model(inputs = img, outputs = validity) # Создаем модель discriminator_conv (входные данные: img, выходные данные: validity) return discriminator_conv # Функция возвращает discriminator_conv
Архитектура сети GAN создается в методе класса build_GAN:
def build_GAN(self): optimizer = Adam(0.0002, 0.5) # Build and compile the discriminator self.discriminator.name = "Discriminator" self.discriminator.compile(loss='binary_crossentropy', optimizer=optimizer, metrics=['accuracy']) # Build and compile the generator self.generator.name = "Generator" self.generator.compile(loss='binary_crossentropy', optimizer=optimizer) # The generator takes noise as input and generated imgs z = Input(shape=(self.latent_dim,), name = "GAN_In") # Создаем слой Input размерностью latent_dim. На входе подается шум, а на выходе - сгенерированные изображения. img = self.generator(z) # For the combined model we will only train the generator self.discriminator.trainable = False # Замораживаем обучение дискриматора, чтобы в объединенной модели тренировался только генератор # The valid takes generated images as input and determines validity valid = self.discriminator(img) #Создаем модель дискриминатора-критика. На вход подаются сгенерированные изображения, а на выходе вероятность распознавания исходных изображений # The combined model (stacked generator and discriminator) takes # noise as input => generates images => determines validity self.combined = Model(z, valid, name = "GAN") # В объединенной модели на входе шум, а на выходе вероятность насколько генератор-творец обманул дискриминатора-критика self.combined.compile(loss='binary_crossentropy', optimizer=optimizer) self.combined.summary()
Я разметил входные и выходные слои именами, чтобы на model.summary() было видно какая модель с какими входами и выходами выводится.
Model: "Generator" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= Generator_In (Dense) (None, 256) 25856 _________________________________________________________________ leaky_re_lu_51 (LeakyReLU) (None, 256) 0 _________________________________________________________________ batch_normalization_31 (Batc (None, 256) 1024 _________________________________________________________________ dense_43 (Dense) (None, 512) 131584 _________________________________________________________________ leaky_re_lu_52 (LeakyReLU) (None, 512) 0 _________________________________________________________________ batch_normalization_32 (Batc (None, 512) 2048 _________________________________________________________________ dense_44 (Dense) (None, 1024) 525312 _________________________________________________________________ leaky_re_lu_53 (LeakyReLU) (None, 1024) 0 _________________________________________________________________ batch_normalization_33 (Batc (None, 1024) 4096 _________________________________________________________________ dense_45 (Dense) (None, 784) 803600 _________________________________________________________________ Generator_Out (Reshape) (None, 28, 28, 1) 0 ================================================================= Total params: 1,493,520 Trainable params: 1,489,936 Non-trainable params: 3,584 _________________________________________________________________ Use new discriminator. Model: "Discriminator" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= Discriminator_In (Conv2D) (None, 28, 28, 4) 40 _________________________________________________________________ leaky_re_lu_54 (LeakyReLU) (None, 28, 28, 4) 0 _________________________________________________________________ flatten_9 (Flatten) (None, 3136) 0 _________________________________________________________________ dense_46 (Dense) (None, 512) 1606144 _________________________________________________________________ leaky_re_lu_55 (LeakyReLU) (None, 512) 0 _________________________________________________________________ Discriminator_Out (Dense) (None, 1) 513 ================================================================= Total params: 1,606,697 Trainable params: 1,606,697 Non-trainable params: 0 _________________________________________________________________ Model: "GAN" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= GAN_In (InputLayer) (None, 100) 0 _________________________________________________________________ Generator (Model) (None, 28, 28, 1) 1493520 _________________________________________________________________ Discriminator (Model) (None, 1) 1606697 ================================================================= Total params: 3,100,217 Trainable params: 1,489,936 Non-trainable params: 1,610,281
Принцип работы GAN в коде Keras
Итак, есть две сети: творец-генератор и критик-дискриминатор. Они объединяются в одну общую сеть GAN, у которой выходы генератора идут на вход дискриминатора. Теперь самый важный момент — это то, как изящно сделана тренировка такой сети.
Я в коде метода в явном виде задал свойство self.discriminator.trainable = True, хотя, в данном случае — это необязательно, поскольку в build_GAN() уже указано, что self.discriminator.trainable = False, т.е. при тренировке объединенной модели веса дискриминатора будут заморожены и тренироваться будет ТОЛЬКО генератор.
def train(self, epochs, batch_size=128, save_interval=1000, save_images = False): # Load the dataset (X_train, _), (_, _) = mnist.load_data() # Rescale -1 to 1 X_train = (X_train.astype(np.float32) - 127.5) / 127.5 # Масштабируем значение в диапазон от -1 до 1, поскоьлку активационная функция tanh, у которого значения лежат от -1 до +1 X_train = np.expand_dims(X_train, axis = 3) # Добавляем третью размерность для X_train ((28,28) => (28,28,1)) valid = np.ones((batch_size, 1)) # Создаем массив единиц длинной batch_size fake = np.zeros((batch_size, 1)) # Создаем массив нулей длинной batch_size for epoch in range(epochs): # ---Train Discriminator--- idx = np.random.randint(0, X_train.shape[0], batch_size) # Выбираем случайным образом batch_size картинок из исходной обучающей выбрки для тренировки дискриминатора imgs = X_train[idx] # В переменную imgs записываем значение из X_train с индексами из idx noise = np.random.normal(0, 1, (batch_size, self.latent_dim)) # Формируем массив размерностью (batch_size, self.latent_dim) из нормально распределенных значений gen_imgs = self.generator.predict(noise) # Формируем массив изображений с помощью входной переменной generator self.discriminator.trainable = True d_loss_real = self.discriminator.train_on_batch(imgs, valid) # Ошибка дискриминатора, обученного на реальных картинках. Передаем в функцию train_on_batch реальные изображения (imgs) и массив единиц (valid). d_loss_fake = self.discriminator.train_on_batch(gen_imgs, fake) # Ошибка дискриминатора, обученного на сгенерированных картинках. Передаем в функцию train_on_batch сгенерированные изображения (gen_imgs) и массив нулей (fake) d_loss = np.add(d_loss_real, d_loss_fake) / 2 # Получаем массив средних ошибок дискриминатора. Поэлементно складываем массивы d_loss_real и d_loss_fake и делим каждое значение пополам # --- Train Generator --- noise = np.random.normal(0, 1, (batch_size, self.latent_dim)) # Формируем массив рамерностью (batch_size, self.latent_dim) из нормально распределенных значений self.discriminator.trainable = False g_loss = self.combined.train_on_batch(x = noise, y = valid) # Получаем ошибку генератора. Передаем в функцию train_on_batch шум (noise) и массив единиц (valid) print ("%d [D loss: %f, acc.: %.2f%%] [G loss: %f]" % (epoch, d_loss[0], 100*d_loss[1], g_loss)) # Plot the progress if (epoch % save_interval == 0) or (epoch == epochs - 1): # Выводим/сохраняем изображения каждые save_interval эпох и в конце цикла self.save_imgs(epoch, save_images)
Основное отличие в коде от привычной тренировки запуском model.fit() — использование метода model.train_on_batch(). Он обучает модель только на одном батче данных. Соответственно, чтобы прогнать обучение по всем эпохам, нужно в цикле вызывать model.train_on_batch().
Генератор на основе шума с нормальным распределением формирует некоторые случайные изображения, которые поступают на вход дискриминатора.
Дискриминатор получает случайные картинки от генератора и промаркированные 0-ми и реальные картинки из обучающей выборки промаркированные 1-ми и подстраивает свои веса так, чтобы отличать одно от другого. В момент, когда вызывается train_on_batch() автоматом проставляется self.discriminator.trainable = True, иначе было бы не логично. Именно поэтому необязательно в явном виде указывать trainable = True.
После того как дискриминатор обучился на актуальных достижениях творца в части самостоятельного воссоздания аналога исходных изображений его веса замораживаются self.discriminator.trainable = False и начинается тренировка общей модели генератор + дискриминатор, при этом на выходе при вызове train_on_batch() в качестве y задается вектор заполненный 1-ми.
При обучении GAN нейронная сеть пытается подобрать веса генератора таким образом, чтобы после подачи на вход шума на выходе формировать такое изображение, чтобы при прохождении через замороженные веса дискриминатора последний распознал бы творение как реальные картинки. Именно поэтому при обучении y = 1.
Исследователи создавшие архитекутуру GAN сделали очень изящный код, хитро сделав обратную связь на генератор от дискриминатора. Дискриминатор на каждой эпохе получает данные с улучшенной версии творца и дает ему рекомендации как лучше себя обмануть. 🙂
Творец на основании этих уроков улучшает свои навыки и на следующей итерации дает дискриминатору новый вариант своего творения. На этих улучшенных работах творца критик вновь проходит обучение, чтобы понять в чем преуспел творец и выискивает изъяны в его работах опять сообщая об этом творцу при обучении общей модели.
Честно говоря, просто обалденно изящно придумано! Не сразу дошло.
Нюансы
Как упоминал Дмитрий Романов наличие сверточного дискриминатора или генератора можно легко отличить по результирующим фото. При использовании сверточных сетей на фото нет редких белых точек, т.е. картинка выглядит ровно черной. В случае с Dense слоями на картинках периодически есть редкие остатки белых точек.
Качество обучения зависит от размеров batch_size. На больших размерах время на эпоху увеличивается, но визуально обучение идет быстрее, т.е. на меньшем количестве эпох получается достойный результат. Сверточная сеть обучается медленнее. Но это все субъективные утверждения, время не засекал.
Параметры точности распознавания при сбалансированных генераторе и дискриминаторе должны находится в диапазоне 60 — 80%. Если вероятность работы дискриминатора близко к 100%, значит, он подавляет генератор, не давая ему обучаться. Нужно либо ослаблять дискриминатор, упрощая сеть, либо усилить генератор, добавив элементы сети.
Если же вероятность около 50%, значит генератор настолько хорош, что дискриминатору сложно распознавать фейки и в этом случае генератор тоже не будет совершенствоваться, у него и так все хорошо, стимула нет что-то менять. 🙂
Полезные ссылки
- A Beginner’s Guide to Generative Adversarial Networks (GANs) — https://skymind.ai/wiki/generative-adversarial-network-gan
- Finding Tiny Faces in the Wild with Generative Adversarial Network — https://neurohive.io/en/state-of-the-art/finding-tiny-faces-in-the-wild-with-generative-adversarial-network/
- Генеративно-состязательная нейросеть (GAN). Руководство для новичков — https://neurohive.io/ru/osnovy-data-science/gan-rukovodstvo-dlja-novichkov/
- 18 Impressive Applications of Generative Adversarial Networks (GANs) — https://machinelearningmastery.com/impressive-applications-of-generative-adversarial-networks/
- Туториал: создание простой GAN на Python с библиотекой Keras — https://neurohive.io/ru/tutorial/simple-gan-python-keras/
- GANSynth: создание музыки с помощью GAN — https://neurohive.io/ru/novosti/gan-for-music-synthesis/
- StyleGAN для генерации новых лиц опубликована в открытом доступе — https://neurohive.io/ru/papers/stylegan-open-source/