По следам неудачного примера из vectorflow. Часть II. Обучаем нейросеть на 60 000 PPM-файлах

В предыдущей части, мы показали как можно использовать D в качестве скриптового языка, который помог перевести множество изображений из базы данных MNIST в более удобный для разбора формат PPM P6. Несмотря на то, что использовалась одна внешняя библиотека (а именно, разработанная нами ppmformats), код по большей части задействует ресурсы стандартной библиотеки языка и остается простым.

Если вы помните, результатом работы программы из Части I являлась папка со множеством файлов формата PPM P6, а в этой статье, мы покажем, как их подать на вход нейронной сети.

Для создания нейронной сети мы по прежнему будем использовать vectorflow, библиотеку для формирования нейронных сетей от Netflix. В одной из статей мы уже описывали принципы работы с этой библиотекой. В тот раз у нас не получилось правильно реализовать работу нейросети. Именно это обстоятельство заставило нас несколько воздержаться от использования vectorflow в своих проектах, поскольку нас расстраивала крайне узкое место в виде написания собственных обработчиков форматов датасетов.

Но, как оказалось далее, неудача в нашем первом эксперименте с нейронными сетями не была связана с тем, что процедура «вытягивания» данных из PNG-картинок была написана криво, и внутреннее устройство vectorflow также не было причиной…

Но обо все по порядку, и для начала полный код удавшегося эксперимента:

import std.algorithm;
import std.conv;
import std.file;
import std.path;
import std.random;
import std.range;
import std.stdio;
import std.string;
import std.typecons;

import ppmformats;

import vectorflow;
import vectorflow.math : fabs, round;

enum DATASET_DIR = "/home/aquareji/D/mnist_data/";

struct Obs
{
    float label;
    float[] features;
}

auto ppm2obs(string filepath, float label = float.nan)
{
    Obs obs;
    auto img = new P6Image;
    img.load(filepath);

    auto features(ref P6Image img)
    {
        float[] vector;

        foreach (e; 0 .. (img.width * img.height))
        {
            vector ~= 255 - img[e].luminance;
        }

        return vector;
    }

    obs.label = label;
    obs.features = features(img);

    return obs;
}

auto recognize(string filepath, ref NeuralNet net)
{
    size_t answer;
    auto obs = ppm2obs(filepath);
    auto weights = net.predict(obs);
    writeln(weights);

    float maxWeight = -float.max;

    foreach (i, e; weights)
    {
        if (e > maxWeight)
        {
            maxWeight = e;
            answer = i;
        }
    }

    return answer;
}

auto loadFromPPM()
{
    auto features(ref P6Image img)
    {
        float[] vector;

        foreach (e; 0 .. (28 * 28))
        {
            vector ~= 255 - img[e].luminance;
        }

        return vector;
    }

    auto fromPPM(string data)
    {
        Obs[] dataset;
        P6Image img = new P6Image;

        foreach (number; 0 .. 10)
        {
            auto files = dirEntries(data ~ number.to!string ~ `/`, SpanMode.shallow);

            foreach (fileName; files)
            {
                Obs obs;
                obs.label = number;
                img.load(fileName);
                obs.features = features(img);
                dataset ~= obs;
                writefln(`Loaded %s`, fileName);
            }
        }

        auto gen = Random(unpredictableSeed);
        return dataset.randomShuffle(gen);
    }

    return tuple(
		fromPPM(DATASET_DIR ~ `training/`), 
		fromPPM(DATASET_DIR ~ `testing/`)
	);
}

void main(string[] args)
{
    if (!`mnist_model.vf`.exists)
    {
        writeln(`Initialize neural network ...`);

        auto nn = NeuralNet()
				.stack(DenseData(28 * 28))
				.stack(Linear(200))
           			.stack(DropOut(0.3))
				.stack(SeLU())
				.stack(Linear(10));
        nn.initialize(0.0001);

        writeln(`Loading datatsets from PPM P6 files ...`);

        auto data = loadFromPPM();
        auto train = data[0];
        auto test = data[1];

        writeln(`Training started ... `);

        nn.learn(train, "multinomial", new ADAM(25, 0.00001, 200), true, 4);

        writeln(`Serialize neural network to mnist_model.vf file ...`);

        nn.serialize(`mnist_model.vf`);

        writeln(`Test  started ...`);

        double error = 0;
        foreach (ref o; test)
        {
            auto predicted = nn.predict(o);
            float maxDeviation = -float.max;
            size_t index = 0;

            foreach (i, f; predicted)
            {
                if (f > maxDeviation)
                {
                    index = i;
                    maxDeviation = f;
                }

                if (fabs(o.label - index) > 1e-4)
                {
                    error++;
                }
            }
        }
        error /= test.length;

        writeln(`Classification error: `, error);
    }
    else
    {
        auto nn = NeuralNet.deserialize("mnist_model.vf");
        recognize(`/home/aquareji/D/mnist_data/testing/4/235.ppm`, nn).writeln;
    }
}

Можно заметить, что код практически полностью совпадает с кодом из самой первой нашей статьи по нейросетям, с той разницей, что вместо dlib используется ppmformats. Соответственно, сердцем программы в нашем эксперименте является процедура загрузки данных из датасетов — loadFromPPM.

Процедура loadFromPPM опирается на описанную ранее структуру Obs, которая включает в себя два поля label и features: первое соответствует подписи для «единичного данного», а второе — набору признаков или вектору признаков. На всякий случай напоминаю, что такая структура данных не наша прихоть, а требование от API библиотеки vectorflow — и поэтому приходиться с этим считаться, а как следствие, процедура loadFromPPM генерирует целый комплект структур Obs, в количестве равном
70 000 штук. Но с возвращаемым типом результата не все так очевидно, о чем пойдет речь далее.

Внутри процедуры loadFromPPM находится процедура features, которая используется для того, чтобы извлечь из изображения формата PPM P6 вектор признаков. Данный вектор представляет собой массив типа float[] и является одним из полей структуры Obs, необходимой для vectorflow.

Процедура features получает на вход ссылку на объект типа P6Image (то есть один из производных типов для Portable AnyMap изображения в библиотеке ppmformats) и выделяет из картинки размером 28х28 (которая хранится в объекте P6Image) яркости каждого пикселя, на ходу осуществляет инвертирование и записывает каждую из них в заранее подготовленный аккумулятор в виде динамического массива.

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

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

Также внутри процедуры loadFromPPM находится процедура fromPPM, которая, по сути, и выполняет всю работу.

Эта процедура принимает в качестве аргумента строку с папкой, где находиться комплект PPM-файлов датасета, и выдает на выходе диапазон из структур Obs. Сам алгоритм работы для fromPPM очень прост: у нас есть определенная структура каталогов с файлами датасета (об этом мы уже рассказывали в Части I), которая предполагает наличие папок с именами в диапазоне 0..9 (по наименованию цифры); и поэтому с помощью простого цикла с перебором по числовому имени (это внешний цикл) осуществляется работа с PPM-файлами, а именнно — конструирование пути для конкретного файла и получения из него вектора признаков с помощью уже знакомой нам features. Для того, чтобы это все работало необходимо заранее создать переменную, хранящую массив типа Obs[] и объект изображения P6Image, которые используются соответственно для накопления структур с векторами признаков — надписями и для загрузки данных изображения из файла.

Для организации загрузки каждого файла из конкретной папки используется dirEntries, которая генерирует диапазон со всеми путями объектов файловой системы (в нашем случае, это файлы), а аргумент SpanMode.shallow говорит о том, что «глубина погружения» (т.е уровень рекурсии) невысок и не затрагивает внутренние папки (поскольку таковых в нашем случае нет). Помимо этого, сделан вывод пути каждого обработанного файла в консоль, т.к программа работает на редкость очень долго и без какой-либо индикации статуса текущей стадии работы может показаться, что программа просто зависла.

Вишенкой на торте в работе процедуры fromPPM является использование алгоритма randomShuffle из std.random, который принимая на вход некоторый диапазон и генератор псевдослучайных чисел, выдает перемешанный случайным образом исходный диапазон. Подобное добавление к простой загрузке данных в нейросеть приводит к коренным изменениям в работе самой нейронной сети — все дело в том, что перемешивание данных способствует наилучшему обучению сети. Причина в том, что однородные данные приводят к своеобразному эффекту памяти при котором нейронная сеть «привыкает» к одному из видов данных и напрочь отказывается «воспринимать» другие их виды.

Внимание, именно отсутствие смешивания данных привело к печальным результатам в самом первом эксперименте, и dlib и парсинг входного формата скорее всего не причем !

Итогом работы всех вышеописанных частей (а также самой процедуры loadFromPPM) является кортеж (tuple) из двух диапазонов с произвольным доступом к элементам (random access range) из структур типа Obs, который будет использован для обучения нейронной сети.

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

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

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

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

Далее, из массива всех весов выбирается наибольший и вычисляется какая цифра ему соответствует (цифра равна индексу наибольшего веса в массиве выданного методом predict), которая затем выдается как ответ от нейронной сети.

Для испытания recognize мы просто взяли первый попавшийся файл из тестирующего датасета и скормили его программе…

Примечание редактора. Если вы намерены это все повторить или использовать, то не забудьте поменять пути в переменной DATASET_DIR, а также в функции recognize.

И в этот раз результат кардинально отличается от прошлого: после 23 минутного обучения на моем компьютере (напоминаю, 2 ядра по 1.35 ГГц) ошибка классификации для нейронной сети составила всего 0.0029 и сеть уверенно опознала цифру 4, а также нарисованную автором от руки цифру 5 — и знаете, это было круто!

Добавить комментарий