Распознавание рукописных чисел MNIST

Запустить в Google Colab

Roundicons.com Посмотреть на GitHub

Скачать ноутбук

Введение

В этом туториале мы рассмотрим работу с библиотекой PuzzleLib на примере обучения классификатора рукописных цифр из датасета MNIST.

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

Зайдите на сайт Яна ЛеКуна (создателя датасета MNIST) и скачайте следующие файлы:

  • t10k-images.idx3-ubyte.gz
  • t10k-labels.idx1-ubyte.gz
  • train-images.idx3-ubyte.gz
  • train-labels.idx1-ubyte.gz

Поместите скачанные файлы в выбранный вами каталог, а затем распакуйте. Получившиеся файлы содержат 70000 чёрно-белых изображений размера 28 на 28 пикселей с рукописными цифрами от 0 до 9.

Пример изображений из MNIST
Пример изображений из MNIST

Работа с MNIST считается Hello World'ом в машинном обучении для работы с изображениями. Задача, которую необходимо решить: по изображению понять, какое число на нём изображено.

Реализация инструментами библиотеки

Начнём с импортирования системной библиотеки os и библиотеки numpy - основной Python-библиотеки для работы с многомерными тензорами.

import os

import numpy as np

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

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

from PuzzleLib.Datasets import MnistLoader

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

from PuzzleLib.Containers import Sequential
from PuzzleLib.Modules import Conv2D, MaxPool2D, Activation, Flatten, Linear
from PuzzleLib.Modules.Activation import relu
from PuzzleLib.Handlers import Trainer, Validator
from PuzzleLib.Optimizers import MomentumSGD
from PuzzleLib.Cost import CrossEntropy
from PuzzleLib.Models.Nets.LeNet import loadLeNet

Функция showFilters используется для сохранения в файл-изображение слоёв нейронной сети. Это полезно для того, чтобы понимать, какие фичи выучила нейронная сеть.

from PuzzleLib.Visual import showFilters

Загрузим данные:

path = "../TestData/"
mnist = MnistLoader()
data, labels = mnist.load(path=path)
data, labels = data[:], labels[:]
print("Loaded mnist")

Important

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

Здесь мы обращаемся к загрузчику MNIST и получаем от него все фотографии в data, а все их ярлыки (т. е. указание, какое число находится на изображении) - в labels.

Можете распечатать в консоль или посмотреть в дебагере, что из себя представляют data и labels. Это массивы данных в формате HDF (Hierarchical Data Format): удобный формат хранения численных данных, который мы используем в библиотеке. Именно в него конвертируется исходный формат MNIST с помощью загрузчика MNIST.

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

np.random.seed(1234)

Построение сети

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

#net = loadLeNet(None, initscheme=None)

Для объединения отдельных вычислительных блоков нейросети в единое целое в библиотеке предусмотрены различные связующие модули (см. Containers), один из которых - это Sequential. Этот модуль является контейнером, в который можно класть другие модули последовательно друг за другом. Мы задали этому модулю имя "lenet-5-like": это имя нейронной сети, которую мы строим. Имя модулям давать необязательно.

seq = Sequential(name="lenet-5-like")

Дальше мы присоединяем к сети первый слой - свёрточный слой:

seq.append(Conv2D(inmaps=1, outmaps=16, size=3))

Метод append присоединяет к сети то, что ему передаётся на вход.

Conv2D - это конструктор свёрточного слоя для двумерного случая. Параметры слоя, которые нас интересуют:

  • inmaps - число входных карт (в нашем случае изображения - чёрно-белые, поэтому карта ровно одна; в случае RGB было бы 3 карты);
  • outmaps - число выходных карт (т. е. сколько различных свёрток пробежит по изображению);
  • size - размер фильтра свёртки (т. е. размер окошка, которым мы пробегаем по изображению, сворачивая его).

Подробнее про свёртку и её параметры можно прочитать в разделе ConvND.

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

seq.append(MaxPool2D())

В нашем случае пулинг создаётся с дефолтными параметрами конструктора (размер окошка 2 на 2 пиксела, шаг, с которым мы двигаем окошко по карте, 2).

Пулингу на вход придёт 16 карт от свёрточного слоя, на выходе макспул даст тоже 16 карт – он не меняет число карт. Но обратите внимание, что макспул уменьшает размер карты, так как идёт по карте окошком с неединичным шагом.

Следующим шагом мы пропускаем результаты от прошлого слоя через нелинейную функцию активации, задаваемую модулем Activation. В данном случае – ReLU:

seq.append(Activation(activation=relu))

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

Здесь мы присоединяем ещё такие же блоки (заметьте, с другими параметрами Conv2D).

seq.append(Conv2D(inmaps=16, outmaps=32, size=4))
seq.append(MaxPool2D())
seq.append(Activation(activation=relu))

На выходе мы будем иметь 32 карты.

После этого мы превращаем данные из многомерной матрицы в вектор, производя уплощение модулем Flatten:

seq.append(Flatten())

Это делается для того, чтобы потом подать данные на вход в линейный слой Linear (в другой терминологии – "полносвязный слой"):

seq.append(Linear(insize=32 * 5 * 5, outsize=1024))

Линейный слой перемножает вектор данных длины l на матрицу размера (l, 1024), выдавая на выходе вектор длиной 1024. Как можно догадаться, первый параметр в линейном слое - размер входных данных, а второй - размер выходных данных.

Размер входных данных равен 32 \times 5 \times 5, поскольку при применении нескольких макспулов размер изображения снизился до 5 \times 5, а число карт стало равно 32 после предыдущего слоя свёртки.

Потом снова добавляем активацию и ещё один линейный слой, который получит на входе 1024 чисел от предыдущего слоя и выдаст нам 10 чисел (ровно столько, сколько у нас цифр от 0 до 9):

seq.append(Activation(activation=relu))
seq.append(Linear(insize=1024, outsize=10))

Готово! Сеть построена.

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

Подготовка к обучению сети

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

optimizer = MomentumSGD()
optimizer.setupOn(seq, useGlobalState=True)
optimizer.learnRate = 0.1
optimizer.momRate = 0.9

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

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

Кроме оптимизатора, нам ещё нужна функция, которая будет оценивать, насколько сеть ошиблась при классификации входной фотографии:

cost = CrossEntropy(maxlabels=10)

Будем делать это с помощью модуля CrossEntropy, реализующего функцию кросс-энтропии. Мы считаем выход сети (10 чисел) - распределением вероятностей по 10 классам цифр (вероятность, что на фото 0, ... , вероятность, что на фото 9). Мы знаем правильный ответ, поэтому мы можем сравнить предсказание сети с ним. Кросс-энтропия это и делает, выдавая одно число, которое показывает, насколько сеть ошиблась.

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

trainer = Trainer(seq, cost, optimizer)

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

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

validator = Validator(seq, cost)

Ему, в отличие от тренера, не нужен оптимизатор, так как нужно только уметь оценивать, насколько сеть seq ошиблась на входных данных. Cost ему это сообщит.

Перейдём непосредственно к обучению.

Обучение сети

Проведём 15 эпох обучения:

for i in range(15):
    trainer.trainFromHost(data[:60000], labels[:60000], macroBatchSize=60000,
    onMacroBatchFinish=lambda train: print("Train error: %s" % train.cost.getMeanError()))

В каждой эпохе мы вызываем сперва обучение сети trainer.trainFromHost. Метод trainFromHost осуществляет обучение (т. е. обновляет коэффициенты сети). Слова "fromHost" лишь означают, что данные, которые мы передаём в этот метод, лежат в оперативной памяти CPU, и их сперва нужно загрузить на GPU. Есть аналогичный метод train, которому нужно передавать массивы, уже лежащие на GPU.

Мы берём 60000 изображений (и их лейблов) и запускаем обучение сети на них. Параметр macroBatchSize нужен, чтобы указать, какое количество фото из всей выборки за раз мы загрузим на GPU. Чем больше - тем лучше, так как обучение пройдёт быстрее. Но иногда фото большие или память у GPU маленькая, и приходится этот параметр подбирать, чтобы скрипт не падал от нехватки GPU-оперативки. Входные данные будут поделены на макробатчи, и обучение будет происходить на них, пока они все не будут пропущены через нейросеть.

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

Когда все макробатчи прошли, мы распечатываем качество работы сети с помощью валидатора:

    Accuracy = 1.0 - validator.validateFromHost(data[60000:], labels[60000:], macroBatchSize=10000)
    print("Accuracy:", Accuracy)

Обратите внимание: мы даём валидатору только те данные, которые сеть никогда не видела (последние 10000 фото).

Потом уменьшаем на 10% скорость обучения сети (это эмпирическое правило, можно этого и не делать, но обучаться будет хуже):

optimizer.learnRate *= 0.9

И, наконец, выводим в файлы два свёрточных слоя сети (это тоже можно не делать, но посмотреть интересно):

showFilters(seq[0].W.get(), os.path.join(path, "conv1.png"))
showFilters(seq[3].W.get(), os.path.join(path, "conv2.png"))

Здесь мы взяли нулевой и третий слои сети, обратились к их матрицам весов, перевели их на CPU с помощью метода get (так как эти матрицы всегда лежат на GPU - там они используются и обучаются), после чего вывели в файлы-картинки функцией showFilters.

После 15 эпох обучения получаются вот такие изображения:

Фильтры первого свёрточного слоя сети lenet (все 16 штук)
Фильтры первого свёрточного слоя сети lenet (все 16 штук)

Фильтры второго свёрточного слоя сети lenet (все 16x32 штук)
Фильтры второго свёрточного слоя сети lenet (все 16 \times 32 штук)

Точность, с которой сеть научилась классифицировать изображения:

print("Accuracy:", Accuracy)
Accuracy: 0.9924