В данной статье будет рассмотрено одно из решений обучающей задачи на платформе 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). Если бы мы его не удаляли, у нас была бы ошибка:
Поэтому всегда важно следить за размерами в своих данных и какие данные вообще удаляются или выделяются в отдельное множество.
После того, как мы выделили обучающую выборку от ответов, пора нормализовать данные. Для этого достаточно поделить все элементы многомерного массива на 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)
Теперь, когда у нас есть обучающая выборка и ответы, необходимо сделать разделение данных на те, которые будут непосредственно участвовать в обучении, и те, на которых модель будет тестироваться.
т. е. требуется разделить обучающую выборку и столбец ответов на две выборки — обучающую и тестовую. Получается небольшая тавтология, ведь обучающая выборка уже есть, а нам нужна ещё одна. Но этому есть логическое объяснение, которое я дам чуть позже, когда модель будет непосредственно учиться.
# Разделяем данные на два набора - для обучающей выборки и для тестирования
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. Ей были переданы такие параметры, как:
Обучающая выборка
Выборка ответов выделенная из обучающей выборки
Размер тестовой выборки (в данном случае — 10% от обучающей)
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 процентов
)
На следующем рисунке представлены визуальные отличия одного и того же изображения, но с различными изменениями (повороты, сдвиги, увеличение размера).
Как мы можем видеть, цифра изменяет своё положение и в целом идут определённые изменения, что можно оценить невооруженным глазом.
На этом мы закончили рассмотрение этапа подготовки данных, перейдём к рассмотрению следующего, одного из самых важных этапов — разработка архитектуры нейронной сети.
Разработка архитектуры нейронной сети
Для начала, приведу полный листинг программного кода архитектуры нейронной сети:
# Создание свёрточной нейронной сети
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).
В общем‑то, это всё что связано с архитектурой сети.
В результате нейронная сеть в совокупности имеет 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.
После отправки результатов на Kaggle результат составил 0.99896.
Тот самый случай, когда локальные результаты оказываются хуже чем публичные.
Можно продолжить идею развития обучающей выборки и даже реализовать алгоритм динамической подгрузки новых данных с аугментацией, чтобы обучение нейронной сети было ещё более эффективным.
Выводы
В данной статье была разработана нейронная сеть для распознавания рукописных цифр для соревнования Digit Recognize. В статье представлены трюки, с помощью которых любой желающий может достичь высоких результатов при отправке решения на соревнование.
В следующей части статьи будет рассмотрена разработка веб‑приложения на React.js, в котором можно будет нарисовать цифру на холсте и отправив на сервер получить определённый ответ (распознанную цифру). Сервер будет написан на Flask и будет разобран алгоритм загрузки весов моделей и работа обученной нейронной сети (и ещё вернёмся к цифре 6...).
Список использованных источников
Исходный код реализованной нейронной сети и обученная модель со score = 0.99896 на Kaggle (public): исходный код
Ссылка на стандартный датасет MNIST на платформе Kaggle: стандартный датасет
Ссылка на соревнование: Digit Recognizer