По следам неудачного примера из vectorflow. Часть I: конверсия датасетов в удобный формат

Порой я, в поисках идей, просматриваю рабочую папку на предмет неудавшихся или провалившихся идей программ, и иногда из отчаяния и безысходности я выбираю то, что перерастет в последствии в нечто захватывающее и интересное…Если говорить более конкретно, то мне попался на глаза наш неудачный эксперимент с нейросетями в библиотеке от Netflix, который закончился весьма плохо, а именно с ошибкой около 41%.

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

Итак, начнем с того, что на тот момент, когда я занимался разбором примера из vectorflow, то плохо представлял как именно устроен формат датасетов и подписей к нем. В этом плане использование сконвертированного в png датасета также привело бы к неудаче, поскольку используются те же самые соглашения, что и в формате MNIST.

Таким образом, надо вникнуть в то, как устроен сам формат датасетов. Как оказалось, формат и подписей, и самих изображений не является чем-то запредельно сложным и трудновыполнимым, напротив, сам формат интуитивно понятен и прост. Более того, я бы сказал, что формат специально оптимизирован под обучение нейросетей и под загрузку очень большого объема данных, но это не мешает тому, чтобы перевести все датасеты базы MNIST в более удобный формат.

Как я говорил уже ранее, для меня самым удобным форматом является любой из PNM-форматов, поэтому я решил основательно подготовиться и перевести базу данных MNIST в формат PPM P6.

Для этого был написан следующий скрипт на D:

import core.time;

import std.algorithm;
import std.conv;
import std.datetime.stopwatch;
import std.file;
import std.process;
import std.range;
import std.stdio;
import std.string;

import std.net.curl;

import ppmformats;

// где взять датасет
enum DATASET_URL = `http://yann.lecun.com/exdb/mnist/`; 
// папка с датасетом
enum BASE_PATH = `/home/aquareji/D/mnist_data/`;

auto getMNIST()
{
	// скачивание четырех баз (2 для обучающего набора, 2 для тестируемого)
	download(DATASET_URL ~ `train-images-idx3-ubyte.gz`, BASE_PATH ~ `train.gz`);
	download(DATASET_URL ~ `train-labels-idx1-ubyte.gz`, BASE_PATH ~ `train_labels.gz`);
	download(DATASET_URL ~ `t10k-images-idx3-ubyte.gz`, BASE_PATH ~ `test.gz`);
	download(DATASET_URL ~ `t10k-labels-idx1-ubyte.gz`, BASE_PATH ~ `test_labels.gz`);

	// распаковка средствами Linux
	spawnShell(`gunzip ` ~ BASE_PATH ~ "train.gz").wait;
	spawnShell(`gunzip ` ~ BASE_PATH ~ "train_labels.gz").wait;
	spawnShell(`gunzip ` ~ BASE_PATH ~ "test.gz").wait;
	spawnShell(`gunzip ` ~ BASE_PATH ~ "test_labels.gz").wait;
}

auto mnist2ppm(string prefix, int numberOfImages)
{
	// таймер для подсчета времени трансформации
	StopWatch sw;
	// обнуление таймера
	sw.reset;
	// пуск по новой
	sw.start;

	File images, labels;
	// изображения
	images.open(BASE_PATH ~ prefix);
	// подписи
	labels.open(BASE_PATH ~ prefix ~ `_labels`);
	// пропуск считывания сигнатур в обоих файлах
	auto signature1 = new ubyte[16];
	auto signature2 = new ubyte[8];
	images.rawRead!ubyte(signature1);
	labels.rawRead!ubyte(signature2);

	auto DATASET_DIR = BASE_PATH ~ prefix ~ `ing/`;

	// счетчики для создания имен файлов с цифрами
	size_t[10] counters;

	// если папка под датасет отсутствует
	if (!DATASET_DIR.exists)
	{
		mkdir(DATASET_DIR);
	}

	// let's get started !
	for (int n = 0; n < numberOfImages; n++) 
	{
		// буфер под пиксели изображения
		auto buffer = new ubyte[28 * 28];
		images.rawRead!ubyte(buffer);
		// буфер под надпись
		auto tmp = new ubyte[1];
		labels.rawRead!ubyte(tmp);
		// надпись однобайтовая, потому в буфере один элемент
		auto label = tmp[0];
		// создать изображение 28х28, формат P6
		auto img = new P6Image(28, 28);

		// переводим байты буфера в пиксели изображения
		foreach (i, e; buffer)
		{
			// формат базы MNIST говорит, что 0 - белый, а 255 - черный
			// нам надо наоборот 
			auto l = 255 - e;
			// т.к изображение полутоновое, и с такой шкалой, то каждый пиксель - это оттенок серого
			img[i] = new RGBColor(l, l, l);
		}

		// папка в которую попадет текущее изображение, название соответствует цифре
		auto currentDir = DATASET_DIR ~ label.to!string ~ `/`;

		// если папка вдруг не создана
		if (!currentDir.exists)
		{
			mkdir(currentDir);
		}

		// генерируем имя, исходя из того, какой картинка идет по счету (для каждой цифры счет независим, поэтому есть набор счетчиков)
		auto name = `%d.ppm`.format(counters[label]);
		// сохраняем
		img.save(currentDir ~ name);
		// задать инкремент для верной генерации имен
		counters[label]++;
	}

	// преобразование завершено
	sw.stop;
	// извлечение данных о времени из таймера
	auto t = sw.peek;
	writefln(`Transformation time for %s dataset is %s `, prefix, t.to!string);
}

void main()
{
	writeln("Downloading and unpacking datasets ...");
	getMNIST;
	writeln("Convert to PPM P6 for train dataset ...");	
	mnist2ppm(`train`, 60_000);
	writeln("Convert to PPM P6 for test dataset ...");	
	mnist2ppm(`test`, 10_000);
}

Говорю сразу, данный скрипт работает только на unix-платформах, поскольку используются соглашения о пути файлов (слеши в путях идут в другую сторону) и одна из утилит командной строки gunzip, которая распаковывает архивы с тренировочным и тестируемым датасетом. Однако, при желании, все это дело легко портируется и на Windows.

Что собственно тут происходит ?

Сначала мы в enum устанавливаем два важных строковых значения: ссылку, по которой расположены все нужные датасеты, и путь, по которому в результате работы скрипта будут размещены результирующие данные.

После этого в дело вступает функция getMNIST, которая скачивает и распаковывает все необходимые файлы: сначала происходит скачивание четырех файлов (2 для обучающего/тренировочного датасета и 2 для тестируемого) с помощью функции download из std.net.curl и последующая распаковка посредством функции spawnShell из std.process, которая запускает некоторый процесс в командной оболочке по строковому описанию команды запуска. В дальнейшем с помощью функции wait опять же из std.process производиться ожидание окончания работы запущенного процесса распаковки. Это действие необходимо для того, чтобы процессы распаковки шли последовательно друг за другом, а не запускались параллельно и не мешали бы работе остальных.

Окончательным результатом работы функции getMNIST являются четыре файла в папке, путь к которой указан в enum под названием BASE_PATH: train + train_labels (тренирующий датасет - картинки и подписи к ним) и test + test_labels (тестирующий датасет), с которыми работает функция mnist2ppm.

В функции mnist2ppm мы сначала создаем объект таймера StopWatch из std.datetime.stopwatch, сбрасываем его с помощью метода reset и запускаем его по новой с помощью метода start. Данное действие позволит посчитать сколько по времени длилась конверсия всего датасета в PPM формат.

Теперь, я поделюсь самым главным секретом, а именно тем, как устроены форматы MNIST. Поскольку в дальнейшем используются особенности этих форматов.

Примечание автора. Мне нигде в интернете, кроме как скользского упоминания в нашем блоге, не удалось найти на русском языке, что именно из себя представляет формат датасетов MNIST. Так что, по моему скромному мнению, в этом деле мы - первые.

Форматов для базы данных MNIST на самом деле два: один для описания картинок, второй - для подписей к ним. При этом, оба формата устроены абсолютно одинаково, за исключением того, что выбираемые данные из файла (как и сами файлы) разные по размеру.

Файл данных (train и test соответственно) начинается с магического числа (сигнатуры формата) размером 32 бит (это 4 байта), и это ичсло представляет собой беззнаковое целое с порядком прочтения high-endian и представляет собой число 2051 в десятичной системе счисления для обоих датасетов. Вслед за сигнатурой идет описания количества изображений в датасете (для обучающего - это 60 000, а для тестирующего - 10 000) также в формате  32-битного беззнакового числа в формате high-endian, после которого идут два байта данных, в которых хранятся длина и ширина изображения (28х28).

Примечание автора. Не уверен, что правильно понимаю, но возможно речь идет о формате big-endian. Но как пишут сами авторы датасета, первым идет MSB (Most Significant Byte) - наиболее значимый байт, т.е сборка числа идет в обратном следованию байтов порядке.

После служебной информации о формате, идет собственно говоря само изображение, и оно тут хранится построчно (т.е используется простой линейных формат, в котором одна строка изображения следует за другой). При этом изображение представлено в оттенках серого, и за один пиксель тут отвечает один байт, но значения яркости перевернуты: 0 значит белый (минимальная яркость, иначе говоря, фон) и 255 - это черный (т.е максимальная яркость, само изображение).

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

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

Здесь также присутствуют 32-битная сигнатура, которая представляет собой число 2049 в десятичной системе счисления, и 32-битное число, указывающее количество подписей для датасета. Однако, после этого идут одиночные байты, каждый из которых включает в себя подпись под одно изображение: тут надо понимать, что подписи идут параллельно с изображениями, но в той же самой последовательности, что фактически выливается в необходимости одновременной работы с обоими файлами.

Примечание автора. Рекомендую вам прочесть статью про первую попытку работы с нейронными сетями, иначе, не совсем будет понятно, что тут происходит.

Скрипт конверсии открывает два файла: с изображениями и с подписями, после чего из обоих файлов "в никуда" считываются некоторые количества байтов (16 для изображений и 8 для подписей), которые нам абсолютно не нужны, поскольку содержат в себе служебную информацию о форматах, упромянутую выше. Затем, создается строка с указанием пути под результат конверсии, который будет представлять собой папку в которой в свою очередь будут лежать 10 других папок (папки с именами от 0 до 9, в каждой из которых лежит изображение со своей цифрой). Папка в своем имени содержит указание на prefix, который представляет собой имя датасета (train или test - обучающий или тестирующий набор) с суффиксом -ing (такое имя выбрано из-за некоторых ограничений, с которыми я столкнулся в ходе работы). И кстати, функция еще и проверяет есть ли папка под датасет с таким префиксом, и если ее нет, то она будет создана.

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

Дальше, все относительно просто: определяется байтовый буфер размером 28х28, в который попадают считанные из файла сырые значения пикселей (точно таким же образом, считывается и значение из файла подписей, только буфер - однобайтный), а потом происходит несложная обработка полученного буфера. Для обработки задействована библиотека ppmformats: создается объект P6Image с размерностью 28х28, производится итерация по буферу с помощью foreach и каждое значение с учетом того факта, что с яркостями у нас все наоборот, преобразуется в свое значение яркости. Значение яркости l используется для конструирования цвета с помощью типа RGBColor, а поскольку яркости представлены в оттенках серого, то значения компонент цвета R, G и B будут одинаковы и равны только что вычисленному значению l.

После создания объекта изображения, создается правильный путь для последующего сохранения файла: так как путь состоит из имени папки (содержит в себе цифровое значение, сгенерированное из прочитанной подписи) и уникального (точнее, независимо вычисляемого) номера, то сначала будет сконструирован правильный путь до папки, а потом с помощью format из std.format будет создано верное имя. Еще один момент достойный упоминания, это независимый подсчет изображений (т.е номер каждой цифры считается отдельно, и при этом счет идет не в общей массе), который реализован здесь с помощью массива счетчиков counters. Данный массив был описан перед основным циклом функции mnist2ppm и содержит в себе ровно 10 элементов, а увеличивается он ровно тогда, когда было закончено сохранение в файл PPM P6 изображения - в качестве индекса массива счетчиков используется подпись, а операцией изменения состояния счетчиков служит обычный инкремент.

Наконец, после того, как самый крупный цикл функции mnist2ppm отработал, с помощью метода stop останавливается таймер, а при помощи метода peek извлекается значение времени, за которое была осуществлена конверсия.

Дальнейшие действия довольно прозаичны: все функции компонуются в main в порядке их следования (т.е сначала скаивание + распаковка, а затем конверсия).

Примечание автора. Вообще, забыл упомянуть, что функция конверсии принимает два параметра prefix - это часть пути к датасету и она совпадает с его наименованием (т.е возможные варианты: train и test) и numberOfImage - количество изображений в основном файле базы MNIST (60 000 для обучающего и 10 000 для тестирующего).

После запуска через rdmd на моем скромном ноутбуке с двумя ядрами частотой 1.35 ГГц имеем следующие результаты:

Downloading and unpacking datasets ...
Convert to PPM P6 for train dataset ...
Transformation time for train dataset is 50 secs, 665 ms, 445 μs, and 9 hnsecs
Convert to PPM P6 for test dataset ...
Transformation time for test dataset is 8 secs, 404 ms, 715 μs, and 2 hnsecs

Согласитесь, довольно таки неплохо для такого объема файлов, которые суммарно занимают 141.9 Мб (сами файлы баз MNIST занимают суммарно где-то около 11 Мб) ?

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

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