В этот раз я распишу еще один неудачный эксперимент и попутно постараюсь сделать небольшое введение в библиотеку 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); } }
Выводы: это был очень интересный опыт, который научил меня тому, что надо аккуратней разбираться в форматах данных и больше напирать на изучение теории симулируемых процессов. Благодаря этому, я понял, насколько все-таки интересно самому разбираться в сторонних файловых форматах и как забавно пытаться их воспроизвести на «иной элементной базе»…
На этом все, и думаю, что в следующий раз я буду лучше подготовлен…
У меня на python такая же проблема была, пока я сеть нормально не настроил. Короче, мучился я долго.