Делаем модель нейросети для распознавания рукописных цифр

Продолжаем тему моделей и на этом занятии создадим уже полноценную полносвязную нейронную сеть для распознавания рукописных цифр БД MNIST:

MNIST – (сокращениеот «Modified National Institute of Standards and Technology») – база данных образцов рукописного написания цифр

Структура сети будет следующей. Каждое изображение размером 28x28 пикселей растянем в вектор длиной 784 элемента и подадим на первый слой из 128 нейронов с функцией активации ReLU:

Далее разместим еще один слой из 10 выходных нейронов с функцией активации softmax. Полученный выходной вектор как раз и будет содержать класс предъявленного изображения. Например, если был изображен 0, то выход y1 должен принимать наибольшее значение; если 1, то максимальный выход – это y2 и так далее.

Опять же, если вы плохо ориентируетесь в нейронных сетях, функциях активации, способах формирования тренировочных выборок, то рекомендую вначале ознакомиться с курсом «Нейронные сети» на этом канале:

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

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

import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
import tensorflow as tf
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical
import numpy as np

И, далее загрузить обучающий и тестовый наборы с помощью функции load_data():

(x_train, y_train), (x_test, y_test) = mnist.load_data()

Это, как раз и есть множества изображений цифр размером 28х28 пикселей. Послеэтого, значения пикселей нужно привести к диапазону [0; 1] и вытянуть в векторы длины 28*28 = 784 элемента:

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_train, содержащий изначально метки классов (номера изображенных цифр) в вектор формата One-hot с помощью функции to_categorical():

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

class DenseNN(tf.Module):
    def __init__(self, outputs, activate="relu"):
        super().__init__()
        self.outputs = outputs
        self.activate = activate
        self.fl_init = False
 
    def __call__(self, x):
        if not self.fl_init:
            self.w = tf.random.truncated_normal((x.shape[-1], self.outputs), stddev=0.1, name="w")
            self.b = tf.zeros([self.outputs], dtype=tf.float32, name="b")
 
            self.w = tf.Variable(self.w)
            self.b = tf.Variable(self.b)
 
            self.fl_init = True
 
        y = x @ self.w + self.b
 
        if self.activate == "relu":
            return tf.nn.relu(y)
        elif self.activate == "softmax":
            return tf.nn.softmax(y)
 
        return y

Смотрите, здесь у конструктора был добавлен еще один параметр activate, определяющий тип функции активации слоя нейронов (по умолчанию ReLu). А в магическом методе __call__ мы применяем к суммам либо relu(), либо softmax(), либо используется линейная функция (то есть, выходы передаются как есть). Во всем остальном этот такой жекласс, как и в предыдущем занятии.

Далее, мы создадим два полносвязных слоя со 128 нейронами и 10:

layer_1 = DenseNN(128)
layer_2 = DenseNN(10, activate="softmax")

И для удобства определим функцию вычисления выходных значений НС при заданных входных x:

def model_predict(x):
    y = layer_1(x)
    y = layer_2(y)
    return y            # layer_2(layer_1(x))

Конечно, ее можно было бы реализовать проще как layer_2(layer_1(x)), но я специально расписал по слоям, чтобы все было предельно понятно.

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

cross_entropy = lambda y_true, y_pred: tf.reduce_mean(tf.losses.categorical_crossentropy(y_true, y_pred))

Здесь y_true, y_pred – это наборы one-hot векторов размером мини-батча, то есть, функция будет применяться сразу к набору данных, а не к одному конкретному наблюдению. Так как на выходе нам нужно получить одно конкретное число, то все значения для каждого наблюдения из мини-батча мы усредняем функцией reduce_mean().

Далее, определим оптимизатор для градиентного спуска. Пусть это будет Adam, как наиболее употребительный в задачах обучения НС с шагом обучения 0,001:

opt = tf.optimizers.Adam(learning_rate=0.001)

Затем, запускаем сам алгоритм обучения. Вначале для его работы определим параметры:

BATCH_SIZE = 32
EPOCHS = 10
TOTAL = x_train.shape[0]

Делаем разбивку по мини-батчам с перемешиванием выборки:

train_dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
train_dataset = train_dataset.shuffle(buffer_size=1024).batch(BATCH_SIZE)

И реализуем цикл обучения, следующим образом:

for n in range(EPOCHS):
    loss = 0
    for x_batch, y_batch in train_dataset:
        with tf.GradientTape() as tape:
            f_loss = cross_entropy(y_batch, model_predict(x_batch))
 
        loss += f_loss
        grads = tape.gradient(f_loss, [layer_1.trainable_variables, layer_2.trainable_variables])
        opt.apply_gradients(zip(grads[0], layer_1.trainable_variables))
        opt.apply_gradients(zip(grads[1], layer_2.trainable_variables))
 
    print(loss.numpy())

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

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

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

y = model_predict(x_test)
y2 = tf.argmax(y, axis=1).numpy()
acc = len(y_test[y_test == y2])/y_test.shape[0] * 100
print(acc)

Здесь вначале получаем набор векторов y, который, затем, пропускаем через функцию argmax() для получения меток классов в виде чисел от 0 до 9. Далее, используя функционал пакета NumPy, определяем процент верной классификации.

Эту же метрику можно посчитать и средствами Tensorflow, используя класс Accuracy(), следующим образом:

acc = tf.metrics.Accuracy()
acc.update_state(y_test, y2)
print(acc.result().numpy() * 100 )

В данном случае, мне показалось, что через NumPy это сделать проще.

После запуска программы у меня качество классификации составило 97%, что весьма неплохо для такой простой нейросети.

И давайте, еще раз, внимательно посмотрим, что же у нас в итоге получилось. Во-первых, мы обучили НС в соответствии с алгоритмом обратного распространения ошибки (backpropagation). Но откуда он здесь взялся? Мы же его явно нигде не прописывали?В этом и заключается «магия» работы вычислительного графа. С его помощью мы вычислили частные производные по всем параметрам первого и второго слоев НС относительно функции кросс-энтропии, а затем, эти градиенты были использованы в алгоритме градиентного спуска с заданным оптимизатором. Видите, как легко и просто реализуются алгоритмы машинного обучения через вычислительный граф в Tensorflow.

Второй важный момент – простота управлением обучаемых и необучаемых параметров. Давайте, ради интереса, сделаем биасы (biases) необучаемыми. Для этого в методе __call__ класса DenseNN достаточно добавить параметр trainable=False у соответствующего тензора:

self.b = tf.Variable(self.b, trainable=False)

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

Вложенные модели

С помощью того же базового класса tf.Module можно создавать и вложенные (многоуровневые) модели. Именно так было бы правильнее реализовать общую модель двухслойной НС:

class SequentialModule(tf.Module):
    def __init__(self):
        super().__init__()
        self.layer_1 = DenseNN(128)
        self.layer_2 = DenseNN(10, activate="softmax")
 
    def __call__(self, x):
        return self.layer_2(self.layer_1(x))

Смотрите, мы здесь определяем общий класс SequentialModule, который будет содержать два полносвязных слоя со 128 нейронами и 10 нейронами. А функция __call__ уже обращается последовательно к этим слоям и возвращает результат работы НС.

Удобство такого представления в том, что мы теперь работаем со всей НС, как с единой моделью. В частности, создавая экземпляр класса:

model = SequentialModule()

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

grads = tape.gradient(f_loss, model.trainable_variables)
opt.apply_gradients(zip(grads, model.trainable_variables))

Здесь коллекция trainable_variables формируется автоматически из тех переменных тензоров (tf.Variable), которые присутствуют в слоях. Таким образом, мы переходим на более высокий уровень абстракции и, как следствие, получаем большую гибкость программы и удобство ее использования.Мало того, у моделиSequentialModule имеется свойствоsubmodules, которое содержит список вложенных моделей:

print(model.submodules)

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

Видео по теме