Keras - введение в функциональное API

На этом занятии мы посмотрим на построение архитектур нейронных сетей под новым углом зрения – с позиции функционального API пакета Keras. Давайте вначале разберемся, что это такое. Предположим, что мы хотим создать следующую модель НС:

И мы уже знаем, что это можно реализовать с помощью класса Sequential, о котором мы говорили на предыдущем занятии. Но эту же самую архитектуру можно описать подобно графам –через связи между слоями. Именно эта идея и положена в основу функционального описания модели.

Давайте посмотрим, как это делается.В начале программы подключим необходимые модули:

import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
 
import tensorflow as tf
from tensorflow.keras.layers import Dense, Input, Conv2D, MaxPooling2D, Flatten
from tensorflow import keras
from tensorflow.keras.datasets import cifar10
import matplotlib.pyplot as plt

И установим зерно датчика случайных чисел в 1, чтобы обеспечить воспроизводимость результатов:

tf.random.set_seed(1)

Затем, сформируем первыедва слоя так, как это мы обычно делали:

input = keras.Input(shape=(32, 32, 3))
x = Conv2D(32, 3, activation='relu')

А, далее, свяжем их между собой, следующим образом:

x = x(input)

Эта строчка и есть элемент функционального API пакета Keras. Но как это работает? Все очень просто. Смотрите, объект input – это тензор специального вида, имеющий типKerasTensor:

<class 'tensorflow.python.keras.engine.keras_tensor.KerasTensor'>

То есть, это объект, представляющий слой, а не конкретные данные. Далее, так как каждый слой в Keras является функтором (реализует магический метод __call__ и может быть вызван как функция), то при подаче ему на вход другого слоя, он делает не вычисления, а формирует связку с указанным слоем. Вот и все.

Конечно, эту связь можно описать короче, в виде:

x = Conv2D(32, 3, activation='relu')(input)

Здесь мы сразу создаем объект и выполняем связывание со слоем input. В итоге, вся структура нейронной сети может быть описана в виде:

input = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation='relu')(input)
x = layers.MaxPooling2D(2, padding='same')(x)
x = layers.Conv2D(64, 3, activation='relu')(x)
x = layers.MaxPooling2D(2, padding='same')(x)
x = layers.Flatten()(x)
x = layers.Dense(256, activation='relu')(x)
x = layers.Dropout(0.5)(x)
output = layers.Dense(10, activation='softmax')(x)

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

model = keras.Model(inputs=input, outputs=output)

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

model.summary()

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

Обучение сверточной нейронной сети

Давайте теперь обучим полученную модель сверточной НС для классификации изображений БД CIFAR-10. Сразу отмечу, что современные нейросети достигают здесь точности около 96,5%, а человек – всего 94%. Посмотрим, что получится у нас.

В этой БД имеется 50 000 полноцветных изображений, размером 32x32 пикселов в обучающей выборке и 10 000 таких же полноцветных изображений в тестовой выборке. Все изображения разбиты на 10 классов (см. рисунок). Наша задача отнести предъявленный образец нужному классу.

Вначале, как всегда, загрузим набор данных и стандартизуем их:

(x_train, y_train), (x_test, y_test) = cifar10.load_data()
 
x_train = x_train / 255
x_test = x_test / 255
 
y_train = keras.utils.to_categorical(y_train, 10)
y_test = keras.utils.to_categorical(y_test, 10)

Затем, укажем оптимизатор и функцию потерь:

model.compile(optimizer='adam',
             loss='categorical_crossentropy',
             metrics=['accuracy'])

После этого, запустим процесс обучения:

model.fit(x_train, y_train, batch_size=64, epochs=20, validation_split=0.2)

В конце программы выполним оценку качества работы модели на тестовой выборке:

print(model.evaluate(x_test, y_test) )

После запуска программы увидим качество распознавания на обучающей выборке в 87%, а на тестовой в 72%, что в целом неплохо, учитывая сложность задачи и простоту архитектуры НС. Предлагаю вам в качестве практики попытаться улучшить качество распознавания изображений БД CIFAR-10.

Реализация сверточных слоев в Tensorflow

Так как этот курс посвящен не только Keras, но и Tensorflow, то я покажу пример реализации сверточных слоев на уровне Tensorflow. Делается относительно просто, но несколько сложнее, чем в Keras. Вначале определим вспомогательный класс слоя:

class TfConv2D(tf.Module):
    def __init__(self, kernel=(3, 3), channels=1, strides=(2, 2), padding='SAME', activate="relu"):
        super().__init__()
        self.kernel = kernel
        self.channels = channels
        self.strides = strides
        self.padding = padding
        self.activate = activate
        self.fl_init = False
 
    def __call__(self, x):
        if not self.fl_init:
            self.w = tf.random.truncated_normal((*self.kernel, x.shape[-1], self.channels), stddev=0.1)
            self.b = tf.zeros([self.channels], dtype=tf.float32)
 
            self.w = tf.Variable(self.w)
            self.b = tf.Variable(self.b)
 
            self.fl_init = True
 
        y = tf.nn.conv2d(x, self.w, strides=(1, *self.strides, 1), padding=self.padding)
 
        if self.activate == "relu":
            return tf.nn.relu(y)
        elif self.activate == "softmax":
            return tf.nn.softmax(y)
 
        return y

Я его взял из одного из предыдущих занятий и полносвязный слой заменил сверточным. Логика работы здесь следующая. Для сверток нужно задать размер фильтра (kernel), число выходных каналов (channels), смещение фильтров в плоскости входных каналов (strides) и режим формирования выходных каналов (padding). То есть, мы прописываем все те же параметры, что и в стандартном сверточном слое.

Затем, при первом вызове магического метода __call__() формируем наборы весовых коэффициентов размерностью:

[kernel_x,kernel_y, input_channels, output_channels]

И еще тензор коэффициентов для смещений (biases) размером:

[output_channels]

После этого обращаемся к ветке tf.nn и вызываем функцию conv2d для применения фильтров к входному сигналу x. К полученному выходному тензору y применяем функцию активации и, таким образом, формируем выход сверточного слоя.

Использовать этот класс можно, следующим образом:

layer1 = TfConv2D((3, 3), 32)
y = layer1(tf.expand_dims(x_test[0], axis=0))
print(y.shape)

Применяя его к первому входному изображению, получаем на выходе 32 канала размерностью 16x 16 отсчетов:

(1, 16, 16, 32)

Далее, можно применить операцию MaxPooling, например, так:

y = tf.nn.max_pool2d(y, ksize=(1, 2, 2, 1), strides=(1, 2, 2, 1), padding="SAME")
print(y.shape)

Сформируется тензор yразмерностью:

(1, 8, 8, 32)

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

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

Модели в функциональном API

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

Если вы не знаете, что такое автоэнкодер, для чего он нужен и как работает, то смотрите занятие по этой теме в курсе «Нейронные сети»:

Курс по нейронным сетям

Вначале опишем модель кодера, используя сверточные слои:

enc_input = Input(shape=(28, 28, 1))
x = Conv2D(32, 3, activation='relu')(enc_input)
x = MaxPooling2D(2, padding='same')(x)
x = Conv2D(64, 3, activation='relu')(x)
x = MaxPooling2D(2, padding='same')(x)
x = Flatten()(x)
enc_output = Dense(8, activation='linear')(x)
 
encoder = keras.Model(enc_input, enc_output, name="encoder")

А, затем, модельдекодера:

dec_input = keras.Input(shape=(8,), name="encoded_img")
x = Dense(7 * 7 * 8, activation='relu')(dec_input)
x = keras.layers.Reshape((7, 7, 8))(x)
x = Conv2DTranspose(64, 5, strides=(2, 2), activation="relu", padding='same')(x)
x = keras.layers.BatchNormalization()(x)
x = Conv2DTranspose(32, 5, strides=(2, 2), activation="linear", padding='same')(x)
x = keras.layers.BatchNormalization()(x)
dec_output = Conv2DTranspose(1, 3, activation="sigmoid", padding='same')(x)
 
decoder = keras.Model(dec_input, dec_output, name="decoder")

Теперь, используя модели encoder и decoder можно на их основе сформировать общую архитектуру автоэнкодера. Воспользуемся для этого функциональным подходом и будем рассматривать каждую модель как один единый элемент (объект):

autoencoder_input = Input(shape=(28, 28, 1), name="img")
x = encoder(autoencoder_input)
autoencoder_output = decoder(x)
 
autoencoder = keras.Model(autoencoder_input, autoencoder_output, name="autoencoder")

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

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

Давайте выполним этот шаг для БД изображений цифр MNIST. Вначале подготовим обучающую выборку:

(x_train, y_train), (x_test, y_test) = mnist.load_data()
 
x_train = x_train.astype("float32") / 255.0
x_test = x_test.astype("float32") / 255.0
y_train = keras.utils.to_categorical(y_train, 10)
y_test = keras.utils.to_categorical(y_test, 10)

Установим оптимизатор Adamдля автоэнкодера и потребуем минимум квадрата ошибки рассогласования между входным и выходным изображениями:

autoencoder.compile(optimizer='adam', loss='mean_squared_error')

После этого можно запускать сам процесс обучения (одной эпохи вполне достаточно для этой задачи):

autoencoder.fit(x_train, x_train, batch_size=32, epochs=1)

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

h = encoder.predict(tf.expand_dims(x_test[0], axis=0))
img = decoder.predict(h)

В результате должны получить изображение похожее на входное. Для этого отобразим исходное изображение и восстановленное:

plt.subplot(121)
plt.imshow(x_test[0], cmap='gray')
plt.subplot(122)
plt.imshow(img.squeeze(), cmap='gray')
plt.show()

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

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

Видео по теме