Изучаем пример из библиотеки vectorflow

В этот раз я распишу еще один неудачный эксперимент и попутно постараюсь сделать небольшое введение в библиотеку vectorflow, которую разработали в Netflix. Дело в том, что сейчас активно развиваются технологии, связанные с машинным обучением и имитацией нейронных сетей человека, поэтому я решил немного вникнуть в эту тему, для чего в течении двух последних недель пытался разобраться, как порвать гегемонию Python с его библиотеками (написаны не на Python, а на C) и поработать с нейронными сетями.

Не советую радоваться тому, что я опишу, но может быть вам удастся то, что не удалось мне.

Во-первых, я абсолютно не разбираюсь в машинном обучении и нейронных сетях (это больше тема Bagomot’а, чем моя), а во-вторых — мое описание будет крайне поверхностным и возможно содержать ошибки. Однако, это не помешает нам провести эксперимент по распознаванию рукописных цифр с помощью своей собственной нейронной сети, которая будет написана целиком на D и будет запущена на основном процессоре компьютера (т.е без привлечения GPU и сторонних процессоров или специализированных ускорителей).

Для начала эксперимента создаем пустой dub-проект и указываем для него в качестве зависимости библиотеку от Netflix под названием vectorflow. Дальше, необходимо открыть в браузере репозиторий проекта vectorflow на GitHub и перейти в папку examples, открыв файл mnist.d, поскольку именно этот файл и является «сердцем» нашего проекта.

Помимо этого, не помешает открыть вики-страницу vectorflow на GitHub, т.к в ней содержиться описание принципа работы с библиотекой, который я сейчас постараюсь вкратце пересказать.

Работа с vectorflow на редкость проста: сначала мы определяем нашу модель, которая представляет собой прямой ацикличный граф (Direct Acyclic Graf — DAG). Этот граф определяет структуру нейронной сети и, по сути, это все что нам необходимо знать об этом: немного почитав исходный код библиотеки, мы находим все основные понятия, которые нам потребуются при определении структуры сети. В частности, нам нужно определить экземпляр типа NeuralNet, потом добавить в него с помощью метода stack входной слой данных (это либо SparseData с размерностью, либо SparseF с вектором признаков), скрытые слои (с помощью экземпляра Linear с указанием количества нейронов), функцию активации каждого слоя (ReLU, SeLU, TanH и другие нелинейные функции, которые определены в vectorflow) и определить выходные нейроны (опять же с помощью Linear указанием количества нейронов).

Но с этим,  можно особо не торопиться, т.к такое определение NeuralNet обычно находиться в функции main() или сторонней функции инициализации, запускаемой из main.

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

В нашем примере, в структуре Obs есть два поля: label (тип float) и features (тип float[]), которые представляют собой метку компонента данных и некий набор характерных элементов данного (т.е по сути, это и есть вектор признаков). После этого, все что нужно — это написать процедуру загрузки датасетов, которая представляет собой некую функцию, которая принимает строку с путем до файла (как вариант, сам файл) и выдает массив структур Obs.

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

В примере mnist.d используются специально подготовленные датасеты MNIST, которые содержат в себе изображения рукописных цифр в виде нормализованных и полутоновых изображений размеров 28 на 28 пикселей. Эти изображения хранятся в особом, плоском формате и представляют собой по сути дела набор яркостей пикселей изображения, которые представлены в вмде чисел от 0 до 255. Также, набор MNIST содержит в себе еще и отдельный файл для подписей под каждое изображение (т.е подпись и есть метка, которая однозначно идентифицирует представленную в наборе цифру, и нетрудно догадаться, что метка равна числовому представлению цифры).

Пример в vectorflow скачивает наборы датасетов (плоские файлы с картинками и подписями для обучающего и тестового набора, т.е получается всего 4 файла), а затем после процедуры разбора входных форматов (т.е генерации массивов структур Obs) запускается процедура обучения нейронной сети.

Обучение сети также выносится в процедуру main и следует сразу за описанием и инициализацией сети. Эта процедура представляет собой вызов метода learn у экзепляра NeuralNet с передачей в качестве параметров переменной с обучающим датасетом (тип Obs[]), типом оптимизации (задается обычной строкой, я видел два варианта: multinomial — полиномиальный и square — метод наименьших квадратов), оптимизатором (встроенный класс vectorflow с параметрами: количество проходов, коэффициент обучения и размер порции данных или же собственноручно написанная процедура оптимизации, структуру которой можно увидеть в вики проекта vectorflow), флагом подробного вывода (true/false) и количеством ядер процессора (число типа int).

Помимо этого, после обучения полученную нейронную сеть можно сериализовать в файл с помощью метода serialize и даже загрузить с помощью уже готовую сеть с помощью обратного метода — deserialize, что используется довольно часто, т.к как обучение сети на центральном процессоре довольно долгий процесс.

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

Теперь код всего примера:

/+ dub.json:
{
    "name": "mnist",
    "dependencies": {"vectorflow": "*"}
}
+/

import std.stdio;
import std.algorithm;

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

static auto data_dir = "mnist_data/";

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

auto load_data()
{
    import std.file;
    import std.typecons;
    if(!exists(data_dir))
    {
        auto root_url = "http://yann.lecun.com/exdb/mnist/"; 
        mkdir(data_dir);
        import std.net.curl;
        import std.process;
        writeln("Downloading training set...");
        download(
            root_url ~ "train-images-idx3-ubyte.gz",
            data_dir ~ "train.gz");
        download(
            root_url ~ "train-labels-idx1-ubyte.gz",
            data_dir ~ "train_labels.gz");
        writeln("Downloading test set...");
        download(
            root_url ~ "t10k-images-idx3-ubyte.gz",
            data_dir ~ "test.gz");
        download(
            root_url ~ "t10k-labels-idx1-ubyte.gz",
            data_dir ~ "test_labels.gz");

        wait(spawnShell(`gunzip ` ~ data_dir ~ "train.gz"));
        wait(spawnShell(`gunzip ` ~ data_dir ~ "train_labels.gz"));
        wait(spawnShell(`gunzip ` ~ data_dir ~ "test.gz"));
        wait(spawnShell(`gunzip ` ~ data_dir ~ "test_labels.gz"));
    }
    return tuple(load_data(data_dir ~ "train"), load_data(data_dir ~ "test"));
}

Obs[] load_data(string prefix)
{
    import std.conv;
    import std.bitmanip;
    import std.exception;
    import std.array;
    auto fx = File(prefix, "rb");
    auto fl = File(prefix ~ "_labels", "rb");
    scope(exit)
    {
        fx.close();
        fl.close();
    }

    T to_native(T)(T b)
    {
        return bigEndianToNative!T((cast(ubyte*)&b)[0..b.sizeof]);
    }

    Obs[] res;
    int n;
    fx.rawRead((&n)[0..1]);
    enforce(to_native(n) == 2051, "Wrong MNIST magic number. Corrupted data");
    foreach(_; 0..3)
        fx.rawRead((&n)[0..1]);
    foreach(_; 0..2)
        fl.rawRead((&n)[0..1]);

    if(prefix == data_dir ~ "train")
        n = 60_000;
    else
        n = 10_000;

    res.length = n;
    ubyte[] pxls = new ubyte[28 * 28];
    foreach(i; 0..n)
    {
        ubyte label;
        fl.rawRead((&label)[0..1]);

        fx.rawRead(pxls);
        res[i] = Obs(label.to!float, pxls.to!(float[]));
    }

    return res;
}


void main(string[] args)
{
    writeln("Hello world!");

    auto nn = NeuralNet()
        .stack(DenseData(28 * 28)) // MNIST is of dimension 28 * 28 = 784
        .stack(Linear(200)) // one hidden layer
        .stack(DropOut(0.3))
        .stack(SeLU()) // non-linear activation
        .stack(Linear(10)); // 10 classes for 10 digits
    nn.initialize(0.0001);

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

    nn.learn(train, "multinomial",
            new ADAM(
                15, // number of passes
                0.0001, // learning rate
                200 // mini-batch-size
                ),
            true, // verbose
            4 // number of cores
    );

    // if you want to save the model locally, do this:
    // nn.serialize("dump_model.vf");
    // if you want to load a serialized from disk, do that:
    // auto nn = NeuralNet.deserialize("mnist_model.vf");

    double err = 0;
    foreach(ref o; test)
    {
        auto pred = nn.predict(o);
        float max_dp = -float.max;
        ulong ind = 0;
        foreach(i, f; pred)
            if(f > max_dp)
            {
                ind = i;
                max_dp = f;
            }
        if(fabs(o.label - ind) > 1e-3)
            err++;
    }
    err /= test.length;
    writeln("Classification error: ", err);
}

Испытание показывает, что ошибка классификации всего 2 %, вы спросите, а почему тогда эксперимент неудачный ?

А вот почему: используя код примера, решил попробовать сделать похожее, но вместо стандартного датасета из MNIST, я воспользовался его нормальным вариантом в форме обычных картинок. Для загрузки картинок я использовал dlib, а загрузчик формата я писал сам, что привело к странному результату — моя сеть дает ошибку почти в 41 % !!!

Вот код эксперимента с vectorflow и dlib:

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

import dlib.image;

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


static auto trainingDir = `mnist_png/training/`;
static auto testingDir = `mnist_png/testing/`;


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

auto loadImage(string filepath)
{
	import std.math : round;

	float[] data;

	auto label = filepath
						.split(`/`)[$-2]
						.to!float;
	
	auto img = load(filepath);
	
	foreach (x; 0..img.width)
	{
		foreach (y; 0..img.height)
		{
			data ~= round(img[x,y].luminance * 255);
		}
	}
	
	return Obs(label, data);
}

auto loadData(string filepath)
{
	return  dirEntries(filepath, SpanMode.depth)
                            .filter!(a => a.isFile)
                            .map!(a => loadImage(a))
                            .array;
}

void main()
{
	if (!"mnist_model.vf".exists)
	{
		auto nn = NeuralNet()
			.stack(DenseData(28 * 28)) // MNIST is of dimension 28 * 28 = 784
			.stack(Linear(200)) // one hidden layer
			.stack(DropOut(0.3))
			.stack(SeLU()) // non-linear activation
			.stack(Linear(10)); // 10 classes for 10 digits
		
		nn.initialize(0.01);
		
		auto trainingSet = loadData(trainingDir);
		
		nn.learn(trainingSet, "multinomial",
				new ADAM(
					15, // number of passes
					0.01, // learning rate
					155 // mini-batch-size
					),
				true, // verbose
				2 // number of cores
		);
		nn.serialize("mnist_model.vf");
	}
	else
	{
		auto testingSet = loadData(testingDir);
		auto nn = NeuralNet.deserialize("mnist_model.vf");
		
		double err = 0;
		
		foreach(ref o; testingSet)
		{
			auto pred = nn.predict(o);
			writeln(pred);
			float max_dp = -float.max;
			ulong ind = 0;
			foreach(i, f; pred)
				if(f > max_dp)
				{
					ind = i;
					max_dp = f;
				}
			if(fabs(o.label - ind) > 1e-3)
				err++;
		}
		err /= testingSet.length;
		writeln("Classification error: ", err);
		}
}

Выводы: это был очень интересный опыт, который научил меня тому, что надо аккуратней разбираться в форматах данных и больше напирать на изучение теории симулируемых процессов. Благодаря этому, я понял, насколько все-таки интересно самому разбираться в сторонних файловых форматах и как забавно пытаться их воспроизвести на «иной элементной базе»…

На этом все, и думаю, что в следующий раз я буду лучше подготовлен…

aquaratixc

Программист-самоучка и программист-любитель

One thought to “Изучаем пример из библиотеки vectorflow”

  1. У меня на python такая же проблема была, пока я сеть нормально не настроил. Короче, мучился я долго.

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