В данной статье будет рассмотрено одно из решений обучающей задачи на платформе Kaggle по распознаванию рукописных цифр. Будет продемонстрирован трюк, который может помочь читателю добиться высоких результатов в данном соревновании. После реализации нейронной сети будет реализовано серверное и веб‑приложение, с помощью которых пользователь сможет рисовать цифры и распознавать их с помощью нейронной сети. Статья ориентирована на начинающих специалистов в области машинного обучения и не носит новаторский характер. Списки на используемые источники (в том числе исходный код) будут представлены в конце статьи. Решения не новы, однако с их помощью можно достичь высоких результатов. Например, автору удалось добиться score равному 0.99896.

Введение

В настоящее время существует большое число решений классической задачи Digit Recognizer на платформе Kaggle. Участники представляют свои решения значение score в которых могут быть самые разные и не всегда удаётся повторить успех автора исходного решения, которое довольно часто разбирается новичками решившими взобраться на вершину лидерборда. Чтобы добиться высоких результатов существующего обучающего набора может быть недостаточно и приходится искать обходные пути с помощью применения различных архитектур или наращивания обучающего набора (часто это делают через аугментацию, но в статье будет рассмотрен ещё один приём). Однако из большого числа найденных мной решений лишь не многие позволяли продвигаться по лидерборду и улучшать показатель своего score на более существенные значения. В каждом решении есть определённые недостатки, но один из них самый существенный и распространяется на все решения и это нехватка данных в датасете — проблема, которая будет решена в рамках данной статьи.

Постановка задачи

Необходимо реализовать нейронную сеть, которая проходит соревнование по распознаванию рукописных цифр на score максимально приближенный к единице.

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

Разработка нейронной сети

Подготовка данных

Для начала отметим, что данных для обучения с соревнования Kaggle достаточно мало, чтобы хорошо обучить модель для распознавания цифр — их всего 42 000:

# Тренировочный датасет MNIST с соревнования Kaggle
train_dataset = np.loadtxt('train.csv', skiprows=1, delimiter=',')
train_dataset.shape # (42000, 785)

Чтобы нейронная сеть выявила признаки нужно сформировать больший датасет, а для этого необходимо воспользоваться инструментом для аугментации данных. Например, таким как ImageDataGenerator

# Генератор новых данных
datagen = ImageDataGenerator(
   rotation_range=10,          # Поворот на случайный угол до 10 градусов
   zoom_range=0.1,             # Увеличение размера до 10 %
   width_shift_range=0.1,      # Сдвих влево / вправо до 10 процентов
   height_shift_range=0.1,     # Сдвиг вверх / вниз до 10 процентов
)

Помимо этого следует воспользоваться трюком, с помощью которого мы увеличим обучающую выборку до 112 000 записей. Заключается трюк в том, чтобы просто объединить обучающий набор из соревнования в Kaggle и обучающий / тестовый наборы из стандартного датасета MNIST, который также расположен на Kaggle.

Достаточно простой трюк. Следующий код его реализует:

# Тренировочный датасет стандартного MNIST
train_mnist = np.loadtxt('mnist_train.csv', skiprows=1, delimiter=',')

# Тестовый датасет стандартного MNIST
test_mnist = np.loadtxt('mnist_test.csv', skiprows=1, delimiter=',')

# Тренировочный датасет MNIST с соревнования Kaggle
train_dataset = np.loadtxt('train.csv', skiprows=1, delimiter=',')

# Объединяем датасеты
dataset = np.concatenate((train_dataset, train_mnist, test_mnist))

print(dataset.shape) # (112000, 785)

Между тренировочным датасетом с соревнования Kaggle и стандартным датасетом MNIST есть небольшая разница: в стандартном датасете записей на 18 000 больше.

train_dataset.shape # (42000, 785)

train_mnist.shape # (60000, 785)

В общем‑то, можно было бы с таким же успехом добавить несколько раз один и тот же датасет предварительно проведя над ним аугментацию, однако на удивление и такой подход неплохо себя показал (генератор работает только при обучении сети). Это будет продемонстрировано позже, после компиляции модели и её обучения.

И да, датасеты абсолютно одинаковые, просто в соревновании не 60 000 записей в обучающей выборке на 10 000 в тестовой, а 42 000 на 28 000. В сумме одно и тоже — 70 000.

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

После того, как мы увеличили нашу обучающую выборку самое время заняться нормализацией данных.

# Выделяем данные для обучения (без первого столбца с ответами)
x_train = dataset[:, 1:]

# Изменяем размер данных обучающей выборки (28x28x2)
x_train = x_train.reshape(x_train.shape[0], 28, 28, 1)

# Размер входа
input_shape = (28, 28, 1)

Как видно из программного кода сначала мы выделяем отдельно обучающую выборку (без ответов, просто данные), а затем изменяем размер обучающей выборке. Изменить размер выборки получилось благодаря удалению столбцов ответов (785 - 1 = 784 = 28×28). Если бы мы его не удаляли, у нас была бы ошибка:

Рисунок 1 - Ошибка если столбец ответов не был удалён
Рисунок 1 - Ошибка если столбец ответов не был удалён

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

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

# Нормализация данных для обучения
x_train /= 255.0

Почему 255, а не, скажем, 127? Или может быть 65? Всё достаточно просто.

В многомерном массива x_train содержится обучающая выборка, каждый элемент который представляет собой определённый массив, состоящий из значений от 0 до 255, и этот диапазон выбран не случайно — это интенсивность цветов, берущая своё начало из цветовой кодировки RGB. Полезный материал по данной теме будет представлен в конце статьи. А мы продолжаем.

Теперь необходимо выделить ответы от обучающей выборки и преобразовать их в формат понятный машине one hot encoding данный формат достаточно популярен в задачах, где необходимо сделать какую‑либо классификацию по категориальным признакам. Сделать это можно с помощью следующего кода:

# Выделяем правильные ответы
y_train = dataset[:, 0]

# Преобразуем ответы в формат one hot encoding
y_train = utils.to_categorical(y_train)
Рисунок 2 - Ответы в формате one hot encoding
Рисунок 2 - Ответы в формате one hot encoding

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

т. е. требуется разделить обучающую выборку и столбец ответов на две выборки — обучающую и тестовую. Получается небольшая тавтология, ведь обучающая выборка уже есть, а нам нужна ещё одна. Но этому есть логическое объяснение, которое я дам чуть позже, когда модель будет непосредственно учиться.

# Разделяем данные на два набора - для обучающей выборки и для тестирования
X_train, X_val, Y_train, Y_val = train_test_split(x_train, y_train, test_size = 0.1, random_state=random_seed)

Разделение обучающей выборки и ответов на под выборки реализовано с помощью утилиты train_test_split. Ей были переданы такие параметры, как:

  1. Обучающая выборка

  2. Выборка ответов выделенная из обучающей выборки

  3. Размер тестовой выборки (в данном случае — 10% от обучающей)

  4. random_state, который равен инициализатору генератора случайных чисел

Для справки (random seed)

Random seed (рандомное зерно) — это значение, используемое для инициализации генератора случайных чисел. Генератор случайных чисел — это алгоритм, который создает последовательность чисел, которая кажется случайной. Random seed позволяет сделать эту последовательность более детерминированной и повторяемой. То есть, указав определенное random seed мы можем получить одинаковую последовательность чисел при каждом запуске генератора случайных чисел.

Генератор псевдослучайных чисел — это алгоритм, который создаёт последовательность чисел, которая кажется случайной. Рандомное зерно позволяет сделать эту последовательность более детерменированной и повторяемой. Указав определённое рандомное зерно мы можем получить одинаковую последовательность чисел при каждом новом запуске генератора псевдослучайных чисел.

Отличный пример использования рандомного зерна — игра Minecraft. С помощью random seed осуществляется генерация случайного мира. Он определяет уникальный идентификатор для каждого мира, который влияет на генерацию ландшафта, распределение блоков, размещение структур и другое. Это позволяет игрокам иметь более контролируемый и предсказуемый процесс генерации мира, а также возможность делится своими игровыми мирами с другими игроками

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

X_train.shape # (100800, 28, 28, 1)

Всё же больше 100 000 записей лучше, чем меньше 50 000.

Теперь рассмотрим пример того, как работает механизм аугментации. т. е. каким образом данные модифицируются.

Ранее в статье я уже описал модель своего ImageDataGenerator

datagen = ImageDataGenerator(
   rotation_range=10,          # Поворот на случайный угол до 10 градусов
   zoom_range=0.1,            # Увеличение размера до 10 %
   width_shift_range=0.1,      # Сдвих влево / вправо до 10 процентов
   height_shift_range=0.1,     # Сдвиг вверх / вниз до 10 процентов
)

На следующем рисунке представлены визуальные отличия одного и того же изображения, но с различными изменениями (повороты, сдвиги, увеличение размера).

Рисунок 3 - Аугментация данных для цифры 6 (к этому числу мы ещё вернёмся ... )
Рисунок 3 - Аугментация данных для цифры 6 (к этому числу мы ещё вернёмся ... )

Как мы можем видеть, цифра изменяет своё положение и в целом идут определённые изменения, что можно оценить невооруженным глазом.

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

Разработка архитектуры нейронной сети

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

# Создание свёрточной нейронной сети

model = Sequential()

# Входной слой
model.add(Input(shape=(28, 28, 1)))
# Добавление строк и столбцов с нулями сверху, снизу, слева и справа от изображения
model.add(ZeroPadding2D(padding=(1, 1), input_shape=(28, 28, 1)))
# Слой свёрточной сети с ядром свёртки размера 5x5 и фильтром 32
model.add(Conv2D(filters = 32, kernel_size = (5,5), padding = 'Same',
                 activation ='relu', input_shape = (28,28,1)))
# Batch-нормализация (ускоряем обучение, стабилизируем нейронную сеть)
model.add(BatchNormalization())
# Слой свёртки с ядром свёртки 5x5 и фильтром 32
model.add(Conv2D(filters = 32, kernel_size = (5,5),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
# Слой функции активации "relu"
model.add(Activation(activations.relu))
# Максимальная операция объединения в пул для 2D-пространственных данных
model.add(MaxPooling2D(pool_size=(2,2)))
# Добавление нулевых строк и столбцов
model.add(ZeroPadding2D(padding=(1, 1)))
# Применение метода регуляризации, который случайным образом отключает от изменения 20% нейронов
model.add(Dropout(0.2))

# Свёрточный слой с ядром 3x3 и фильтром 64
model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Activation(activations.relu))
model.add(MaxPooling2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.2))

# Свёрточный слой с ядром 3x3 и фильтром 256
model.add(Conv2D(filters = 256, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Conv2D(filters = 256, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Activation(activations.relu))
model.add(MaxPooling2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.3))

# Сглаживание многомерных тензоров в одно измерение (конвертация в вектор размера 1xN)
model.add(Flatten())
# Полносвязный слой с функцией активации "relu"
model.add(Dense(128, activation = "relu"))
model.add(Dropout(0.4))
# Полносвязный слой с функцией активации "softmax" (категоризация)
model.add(Dense(10, activation = "softmax"))

Архитектура нейронной сети представляет собой последовательную нейронную сеть с множеством свёрточных и полносвязных слоёв.

Входной слой задаётся явным образом через соответствующий layer и размер данных поступающих на вход (для одного изображения) — 28×28, с одним каналом.

Сразу после входного слоя расположен слой ZeroPadding2D, который добавляет определённое число нулевых строк и столбцов.

После ZeroPadding2D данные переходя в свёрточный слой с ядром свёртки 5×5 и количеством фильтров 32, а затем идёт batch‑нормализация для стабилизации работы нейронной сети и ускорения её обучения.

Затем все данные проходят через функцию активации relu, после которой идёт слой MaxPooling2D и ZeroPadding2D с Dropout'ом, который временно не изменяет какой‑то процент нейроннов.

Далее идут два аналогичных блока, только у каждого из них на свёрточных слоях меняются ядра свёртки и количество фильтров.

В самом конце происходит сглаживание многомерных тензоров в одно измерение (иными словами многомерный массив становится одномерным), после чего данные проходят ещё через один слой с функцией активацией relu, Dropout'ом и на выходе получаем результат работы функции активации softmax, которая определила класс цифры на изображении (от 0 до 9).

В общем‑то, это всё что связано с архитектурой сети.

Рисунок 4 - Результат сборки архитектуры нейронной сети
Рисунок 4 - Результат сборки архитектуры нейронной сети

В результате нейронная сеть в совокупности имеет 1 348 202 параметра. Это не много, но достаточно для достижения хорошего результата.

Перейдём в написанию callbacks для нейронной сети.

Callbacks

Колбэки (или callbacks) — это отличный инструмент, который может быть полезен в течении всего обучения нейронной сети.

Для тех, кто знаком с backend-разработкой

Callbacks можно грубо интерпретировать как middleware в серверном программировании когда перед обработкой нового запроса сначала обрабатываются callbacks, а лишь затем сам запрос (или наоборот — сначала запрос, а затем callbacks). Callbacks является чем‑то вроде middleware, только в машинном обучении.

Для начала, следует определить колбэк, который отработает тогда, когда нейронная сеть зафиксировала свой лучший результат во время обучения. Иными словами — нужно периодически «сохраняться», сохраняя лучший результат. С этим нам поможет колбэк ModelCheckpoint.

# Колбэк для сохранения лучшего варианта работы нейронной сети
checkpoint = ModelCheckpoint('mnist-cnn.hd5',
                             monitor='val_accuracy',     # Доля правильных ответов на проверочном множестве
                             save_best_only=True,        # Сохраняем только лучший результат
                             verbose=1)                  # Вывод логов

В качестве критерия мониторинга мы выбрали val_accuracy (множество правильных ответов) и указали, что мы сохраняем только лучший результат в определённую папку.

Следующий колбэк необходим для того, чтобы нейронная сеть двигалась дальше в случае если результат остаётся прежним какое‑то число итераций (т. е. обучение не улучшается). Данный колбэк называется ReduceLROnPlateau и определяется следующим образом:

# Колбэк для изменения скорости обучения
learning_rate_reduction = ReduceLROnPlateau(monitor='val_accuracy', # Метрика
                                            patience=3,             # Число эпох без улучшения результата
                                            verbose=1,              # Вывод логов
                                            factor=0.5,             # Коэффициент на который мы будем умножать скорость обучения сети
                                            min_lr=0.00001)         # Минимальная скорость обучения сети

Наиболее важными параметрами в этом колбэке выступают patience, factor и min_lr.

Параметр patience отвечает за число эпох (или итераций), преодолев которые нужно умножать скорость обучения на параметр factor, а min_lr определяет насколько минимально мы можем уменьшить скорость обучения.

После всех необходимых процедур определяем размер мини‑выборки для обучения и запускаем сам процесс обучения:

# Размер мини-выборки
batch_size = 32

# Сборка модели с сохранением истории изменений
history = model.fit(datagen.flow(X_train, Y_train, batch_size=batch_size),
                    epochs=35,
                    validation_data=(X_val, Y_val),
                    steps_per_epoch=X_train.shape[0] // batch_size,
                    verbose=1,
                    callbacks=[checkpoint, learning_rate_reduction],
                    shuffle=True)

Возвращаемся к теме тавтологии обучающих и тестовых выборок. Как видно из кода в datagen.flow передаются данные для обучения X_train (данные) и Y_train (метки). Эти данные являются под выборкой обучающей выборки которую мы разделили с помощью утилиты train_test_split. Но также из обучающей выборки мы взяли данные и для тестирования — X_val (данные) и Y_val (метки). При каждой эпохе обучения у нас генерируются данные с помощью ImageDataGenerator и проверяются с помощью тестирующей выборки. Таким образом нейронная сеть всегда получает почти уникальную обучающую выборку, часть из которой максимальна приближена к тестирующей под выборке и тестирующей выборке (которая не участвовала в объединении выборок).

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

После порядка 30 итераций максимальный результат данной нейронной сети и небольшой хитрости с выборками был получен результат в score = 0.9986, что продемонстрировано на рисунке ниже. Результат локально, а не на Kaggle.

Рисунок 5 - Визуализация результатов обучения нейронной сети
Рисунок 5 — Визуализация результатов обучения нейронной сети

После отправки результатов на Kaggle результат составил 0.99896.

Рисунок 5 — Визуализация результатов обучения нейронной сети
Рисунок 6 - Подтверждение результата

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

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

Выводы

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

В следующей части статьи будет рассмотрена разработка веб‑приложения на React.js, в котором можно будет нарисовать цифру на холсте и отправив на сервер получить определённый ответ (распознанную цифру). Сервер будет написан на Flask и будет разобран алгоритм загрузки весов моделей и работа обученной нейронной сети (и ещё вернёмся к цифре 6...).

Список использованных источников

  1. Исходный код реализованной нейронной сети и обученная модель со score = 0.99896 на Kaggle (public): исходный код

  2. Ссылка на стандартный датасет MNIST на платформе Kaggle: стандартный датасет

  3. Ссылка на соревнование: Digit Recognizer

Комментарии (0)