Введение в модели и слои бэкэнда Keras

Когда мы с вами рассматривали нейронную сеть для классификации изображений цифр, то самостоятельно создавали полносвязный слой нейронов. Однако, это типовая задача, которая, конечно же, уже реализована в Tensorflowи в ветке:

tf.keras.layers

можно найти множество разных типов слоев для их использования в нейронных сетях. В частности, класс Dense:

tf.keras.layers.Dense

как раз и реализует полносвязные слои. Здесь слово Keras – это название официального бэкенда, разработанного для упрощения работы с нейронными сетями в Tensorflow. Мы его с вами уже использовали в курсе «Нейронные сети»:

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

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

Создание собственных слоев

Базовым классом для описания слоев в Kerasявляется класс:

tf.keras.layers.Layer

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

class DenseLayer(tf.keras.layers.Layer):
    def __init__(self, units=1):
        super().__init__()
        self.units = units
 
    def build(self, input_shape):
        self.w = self.add_weight(shape=(input_shape[-1], self.units),
                                 initializer="random_normal",
                                 trainable=True)
        self.b = self.add_weight(shape=(self.units,), initializer="zeros", trainable=True)
 
    def call(self, inputs):
        return tf.matmul(inputs, self.w) + self.b

Обратите внимание, в методе buid используется метод add_weight() для инициализации переменных в виде тензора заданной формы. Этот метод также появился благодаря базовому классу Layer. Далее, у нас идет метод call(), который вызывается после инициализации переменных и формирует выходное значение слоя в виде тензора. В частности, здесь описана формула для полносвязного слоя НС. При этом, магический метод __call__() переопределять уже не нужно, это сделано в базовом классе и, как раз, благодаря этому, мы имеем возможность использовать новые методы buid() и call().

Далее, можно создать экземпляр этого слоя (класса):

layer1 = DenseLayer(10)

и пропустить через него какой-либо тензор:

y = layer1(tf.constant([[1., 2., 3.]]))
print( y )

У нашего слоя автоматически появляются следующие полезные свойства:

  • layer1.weights – тензор, содержащий все веса слоя;
  • layer1.trainable_weights – тензор, содержащий все обучаемые веса слоя;
  • layer1.non_trainable_variables – тензор, содержащий все необучаемые веса слоя.

Вложенные слои

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

class NeuralNetwork(tf.keras.layers.Layer):
    def __init__(self):
        super().__init__()
        self.layer_1 = DenseLayer(128)
        self.layer_2 = DenseLayer(10)
 
    def call(self, inputs):
        x = self.layer_1(inputs)
        x = tf.nn.relu(x)
        x = self.layer_2(x)
        x = tf.nn.softmax(x)
        return x

Мы здесь определили два вложенных слоя layer_1 и layer_2 и функцию call() для обработки входного тензора с помощью этих слоев. В результате, можно создать общую модель НС:

model = NeuralNetwork()

и воспользоваться ей, например, так:

y = model(tf.constant([[1., 2., 3.]]))
print( y )

При этом, все параметры вложенных слоев этой общей модели будут автоматически доступны через те же самые свойства:

model.weights, model.trainable_variables, model.non_trainable_variables

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

Класс tf.keras.Model

Однако, класс NeuralNetwork все же следует воспринимать как модель слоя, сложного слоя, состоящего из нескольких вложенных слоев. Но это по прежнему слой. Если нам нужна полноценная модель НС, которую можно:

  • обучать (метод fit());
  • сохранять и загружать веса (методы save(), save_weights(), load_weights());
  • оценивать и прогнозировать выходные значения (методы evaluate(), predict()),

то следует использовать базовый класс:

tf.keras.Model

Чтобы превратить NeuralNetwork в модель пакета Keras, достаточно изменить базовый класс:

class NeuralNetwork(tf.keras.Model):
…

А все остальное оставить без изменений. Теперь, вместо того, чтобы отдельно прописывать функцию потерь, оптимизатор и метрику, достаточно взывать метод compile() экземпляра класса NeuralNetwork и передать требуемые параметры, например, так:

model.compile(optimizer=tf.optimizers.Adam(learning_rate=0.001),
             loss=tf.losses.categorical_crossentropy,
             metrics=['accuracy'])

Смотрите, мы сразу создаем оптимизатор при вызове метода, а в качестве функции потерь передаем ссылку tf.losses.categorical_crossentropy. Этого вполне достаточно, чтобы модель воспользовалась этими параметрами в процессе обучения.

Конечно, оптимизатор Adam и категориальная кросс-энтропия – это стандартные параметры, поэтому в Keras для них зарезервированы соответствующие константы и этот же самый метод можно записать и в таком виде:

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

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

Обучение модели нейронной сети

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

(x_train, y_train), (x_test, y_test) = mnist.load_data()
 
x_train = x_train / 255
x_test = x_test / 255
 
x_train = tf.reshape(tf.cast(x_train, tf.float32), [-1, 28*28])
x_test = tf.reshape(tf.cast(x_test, tf.float32), [-1, 28*28])
 
y_train = to_categorical(y_train, 10)
y_test_cat = to_categorical(y_test, 10)

И запустим процесс обучения с помощью метода fit():

model.fit(x_train, y_train, batch_size=32, epochs=5)

Смотрите, как API пакета Keras облегчают работу с нейронными сетями. Вместо цикла обучения с разбивкой на мини-батчи, вычислением градиентов и изменением параметров модели, достаточно записать всего одну строчку.

В конце программы вызовем метод evaluate() для оценки качества классификации на тестовой выборке:

print( model.evaluate(x_test, y_test_cat) )

После запуска программы увидим похожий результат распознавания, который мы с вами получили на предыдущих занятиях. Только здесь программа получилась гораздо компактнее.

Конечно, метод fit() реализует стандартный алгоритм обучения НС и он подходит для многих прикладных задач. Однако, бывают ситуации, когда требуется использовать свой алгоритм обучения НС. И здесь уже приходится опускаться на уровень Tensorflow так, как мы это делали с вами в предыдущих занятиях. Поэтому, при разработке нейронных сетей, полезно уметь работать и с высокоуровневым APIпакета Keras и напрямую с Tensorflow. Хорошая новость здесь в том, что мы легко можем комбинировать оба подхода. Например, описать модель сети на уровне Keras, а обучение провести с помощью Tensorflow. Это относительно частая ситуация, например, при разработке генеративно-состязательных сетей или различных автоэнкодеров.

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

Методы add_loss() и add_metric()

Предположим, что мы хотим в полносвязном слое DenseLayer дополнительно контролировать величину квадрата весовых коэффициентов и сделать так, чтобы при обучении сети она также учитывалась, то есть, минимизировалась. Для этого в методе call() можно сначала вычислить средний квадрат весовw:

regular = tf.reduce_mean(tf.square(self.w))

а, затем, добавить эту величину в список функций потерь нашей модели:

self.add_loss(regular)

В итоге, при запуске процесса обучения методом fit() общие потери будут определяться суммой двух функций:

loss = categorical_crossentropy + regular

Соответственно, градиенты будут учитывать уже две функции и обучение сети будет происходить несколько иначе.

Чтобы заметить эффект от второй функции, умножим ее на 100:

regular = 100.0 * tf.reduce_mean(tf.square(self.w))

Видим, что качество обучения снизилось с 97% до 92%. То есть, наша дополнительная функция потерь негативно сказалась на результатах обучения.

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

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

то там результирующая функция потерь, как раз состояла из суммы двух функций (среднего квадрата ошибок и дивергенции Кульбака-Лейблера):

В этой задаче вполне можно было бы в модели прописать дивергеницию, а квадрат ошибок задать в виде стандартной функции в методе compile().

Если в процессе обучения нам хотелось бы видеть значения нашей функции потерь (или какую-либо другую метрику), то в том же методе call() можно прописать вызов метода:

self.add_metric(regular, name="mean square weights")

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

На этом занятии мы сделали шаг от Tensorflow к Keras и увидели, как можно создавать слои и модели с помощью классов Layer и Model. На следующих занятиях мы продолжим знакомиться с основными возможностями API Keras, так как он естественным образом упрощает разработку нейронных сетей на Tensorflow.

Видео по теме