Тесты

Работа с набором данных MNIST для распознавания изображений

Джеймс Маккафри

Исходный код можно скачать по ссылке.

Джеймс МаккафриОдна из наиболее интересных областей машинного обучения — распознавание изображений (image recognition, IR). Примеры систем, где применяется IR, — программы доступа к компьютеру, которые используют идентификацию по отпечатку пальца или по сетчатке глаза, и системы безопасности в аэропортах, которые сканируют лица пассажиров в поисках тех, кто находится в розыске. Набор данных MNIST — это набор простых изображений, пригодных для экспериментов с алгоритмами IR. В этой статье представлена и объясняется сравнительно простая программа на C#, которая знакомит с набором данных MNIST, а тот в свою очередь знакомит с концепциями IR.

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

Лучший способ понять цель этой статьи — взглянуть на демонстрационную программу на рис. 1. Это классическое приложение Windows Forms. Кнопка с меткой Load Images считывает в память стандартный набор данных для распознавания изображений, называемый набором данных MNIST. Он состоит из 60 000 написанных от руки цифр в диапазоне 0–9, которые потом были оцифрованы. Демонстрационная программа позволяет отображать текущее выбранное изображение как растровое (в левой части рис. 1) и как матрицу значений пикселей в шестнадцатеричной форме (в правой части).

Отображение изображений MNIST
Рис. 1. Отображение изображений MNIST

В следующих разделах я подробно рассмотрю код этой демонстрационной программы. Поскольку это приложение Windows Forms, большая часть кода относится к функциональности UI и содержится в нескольких файлах. Я сосредоточусь только на логике. В связи с этим я переработал демонстрационный код в единый файл исходного кода на C#, который можно скачать по ссылке msdn.microsoft.com/magazine/msdnmag0614. Чтобы скомпилировать его, сохраните этот файл на своем компьютере как MnistViewer.cs, затем создайте новый проект Visual Studio и добавьте в него этот файл. В качестве альтернативы можно запустить командную оболочку Visual Studio (которой известно местонахождение компилятора C#), перейти в каталог, где вы сохранили скачанный файл, и ввести команду >csc.exe /target:winexe MnistViewer.cs, чтобы создать исполняемый файл MnistViewer.exe. Но, прежде чем пытаться запускать демонстрационную программу, вам потребуется скачать и сохранить два файла данных MNIST, как поясняется в следующем разделе, и отредактировать исходный код демонстрационной программы так, чтобы он указывал на местоположение этих двух файлов.

В этой статье предполагается, что вы по крайней мере на среднем уровне владеете навыками программирования на C# (или аналогичном языке), но ничего не знаете о IR. Код демонстрационной программы активно использует Microsoft .NET Framework, поэтому рефакторинг этой программы под язык, не работающий с .NET, например JavaScript, будет трудной задачей.

Терминология, используемая в литературе по IR, весьма заметно варьируется. Распознавание изображений может называться классификацией изображений, распознаванием шаблонов, сравнением шаблонов (pattern matching) или классификацией шаблонов. Хотя эти термины на самом деле имеют разный смысл, иногда они употребляются как взаимозаменяемые, что несколько затрудняет поиск релевантной информации в Интернете.

Набор данных MNIST

Смешанный набор данных Национального института стандартов и технологий (mixed National Institute of Standards and Technology, MNIST) был создан исследователями IR в качестве эталона для сравнения различных алгоритмов IR. Основная идея в том, что, если у вас есть какой-то алгоритм или программная система IR, которую надо протестировать, вы можете запустить свой алгоритм или систему с использованием набора данных MNIST и сравнить результаты с ранее опубликованными для других систем.

Набор данных состоит всего из 70 000 изображений: 60 000 обучающих (используемых для создания модели IR) и 10 000 тестовых (применяемых для оценки точности модели). Каждое изображение MNIST — это оцифрованная картинка одной цифры, написанной от руки. Каждое изображение имеет размер 28 × 28 пикселей. Каждое значение пикселя лежит в диапазоне от 0 (представляет белый цвет) до 255 (представляет черный цвет). Промежуточные значения отражают оттенки серого. На рис. 2 показаны первые восемь изображений в обучающем наборе. Сама цифра, которая соответствует каждому изображению, очевидна человеку, но для компьютеров идентификация цифр — очень сложная задача.

Первые восемь обучающих изображений MNIST
Рис. 2. Первые восемь обучающих изображений MNIST

Любопытно, что и обучающие, и тестовые данные хранятся в двух файлах, а не в одном. Один файл содержит значения пикселей для изображений, а другой — метки изображений (0–9). Каждый из четырех файлов также содержит заголовочную информацию, и все они хранятся в двоичном формате, сжатом в формате gzip.

Обратите внимание на рис. 1, что демонстрационная программа использует только обучающий набор из 60 000 элементов. Формат тестового набора идентичен таковому для обучающего набора. Основной репозитарий для файлов MNIST в настоящее время находится на yann.lecun.com/exdb/mnist. Обучающие пиксельные данные хранятся в файле train-images-idx3-ubyte.gz, а обучающие маркерные данные — в файле train-labels-idx1-ubyte.gz. Чтобы запустить демонстрационную программу, вам понадобится перейти на сайт репозитария MNIST, скачать и разархивировать эти два файла обучающих данных. Чтобы разархивировать файлы, я использовал бесплатную программу 7-Zip с открытым исходным кодом.

Создание средства просмотра MNIST

Чтобы создать демонстрационную программу MNIST, я запустил Visual Studio и создал новый проект C# Windows Forms с именем MnistViewer. Эта программа не имеет значимых зависимостей от конкретной версии .NET, поэтому подойдет любая версия Visual Studio.

После загрузки кода шаблона в редактор Visual Studio я подготовил UI. Я добавил два элемента управления TextBox (textBox1, textBox2) для хранения путей к двум распакованным файлам с обучающими данными, элемент управления Button (button1) с меткой Load Images, а также еще два TextBox (textBox3, textBox4) для хранения индексов текущего и следующего изображений. С помощью дизайнера Visual Studio я настроил начальные значения для этих элементов управления как «NA» и «0» соответственно.

Для значения, определяющего масштаб изображения, я добавил элемент управления ComboBox (comboBox1). Используя дизайнер, я перешел к набору Items этого элемента и вставил строки от «1» до «10». Потом добавил второй элемент управления Button (button2) и присвоил ему метку Display Next. Далее добавил элемент управления PictureBox (pictureBox1) и установил его свойство BackColor в ControlDark, чтобы можно было видеть контур этого элемента. Размер PictureBox я указал как 280 × 280, что обеспечивает масштабирование до 10 раз (вспомните, что изображение MNIST имеет размер 28 × 28 пикселей). Затем я добавил пятый TextBox (textBox5) для отображения шестнадцатеричных значений изображения, установил его свойство Multiline в True, а Font — как «Courier New, 8.25 pt.», после чего расширил его размер до 606 × 412. И наконец, добавил ListBox (listBox1) для сообщений, связанных с протоколированием.

Разместив эти UI-элементы на Windows-форме, я добавил три поля, видимых только в пределах класса:

public partial class Form1 : Form
{
  private string pixelFile =
    @"C:\MnistViewer\train-images.idx3-ubyte";
  private string labelFile =
    @"C:\MnistViewer\train-labels.idx1-ubyte";
  private DigitImage[] trainImages = null;
...

Первые две строки указывают местоположение распакованных файлов с обучающими данными. Для запуска демонстрационной программы вам придется отредактировать эти две строки. Третье поле — это массив определенных в программе объектов DigitImage.

Я слегка изменил конструктор Form, чтобы поместить файловые пути в textBox1 и textBox2 и задать для масштабирования начальное значение 6:

public Form1()
{
  InitializeComponent();
  textBox1.Text = pixelFile;
  textBox2.Text = labelFile;
  comboBox1.SelectedItem = "6";
  this.ActiveControl = button1;
}

Свойство ActiveControl указывает, что изначально фокус ввода помещается на элемент управления button1; это сделано просто для удобства.

Создание класса для хранения изображения MNIST

Я создал небольшой класс-контейнер, представляющий одно изображение MNIST (рис. 3), и назвал его DigitImage, но вы можете переименовать его в нечто более специфическое, например MnistImage.

Рис. 3. Определение класса DigitImage

public class DigitImage
{
  public int width; // 28
  public int height; // 28
  public byte[][] pixels; // 0 (белый) – 255 (черный)
  public byte label; // '0' - '9'
  public DigitImage(int width, int height, 
    byte[][] pixels, byte label)
  {
    this.width = width; this.height = height;
    this.pixels = new byte[height][];
    for (int i = 0; i < this.pixels.Length; ++i)
      this.pixels[i] = new byte[width];
    for (int i = 0; i < height; ++i)
      for (int j = 0; j < width; ++j)
        this.pixels[i][j] = pixels[i][j];
    this.label = label;
  }
}

Я объявил все члены класса как public для простоты и убрал обычную обработку ошибок, чтобы сохранить малый размер кода. Поля width и height можно было бы опустить, так как все изображения MNIST имеют размер 28 × 28 пикселей, но их добавление придает классу больше гибкости. Поле pixels — это матрица в стиле массив массивов. В отличие от многих языков в C# есть настоящий многомерный массив, и, возможно, вы предпочтете использовать именно его вместо матрицы. Каждая ячейка имеет тип byte — целочисленное значение в диапазоне 0–255. Поле label также объявлено с типом byte, но могло бы иметь тип int, char или string.

Конструктор класса DigitImage принимает параметры width, height, pixels (матрицу) и label и просто копирует значения этих параметров в соответствующие поля. Я мог бы копировать pixels по ссылке, а не по значению, но это могло бы привести к нежелательным побочным эффектам при изменении значений исходной матрицы пикселей.

Загрузка данных MNIST

Для регистрации обработчика события кнопки button1 я просто дважды щелкаю ее. Этот обработчик большую часть работы отдает методу LoadData:

private void button1_Click(object sender, EventArgs e)
{
  this.pixelFile = textBox1.Text;
  this.labelFile = textBox2.Text;
  this.trainImages = LoadData(pixelFile, labelFile);
  listBox1.Items.Add("MNIST images loaded into memory");
}

Метод LoadData представлен на рис. 4. LoadData открывает оба файла (с пиксельными и маркерными данными) и одновременно считывает их. Метод начинает с создания локальной матрицы пиксельных значений 28 × 28. Удобный .NET-класс BinaryReader предназначен специально для чтения двоичных файлов.

Рис. 4. Метод LoadData

public static DigitImage[] LoadData(string pixelFile, string labelFile)
{
  int numImages = 60000;
  DigitImage[] result = new DigitImage[numImages];
  byte[][] pixels = new byte[28][];
  for (int i = 0; i < pixels.Length; ++i)
    pixels[i] = new byte[28];
  FileStream ifsPixels = new FileStream(pixelFile, FileMode.Open);
  FileStream ifsLabels = new FileStream(labelFile, FileMode.Open);
  BinaryReader brImages = new BinaryReader(ifsPixels);
  BinaryReader brLabels = new BinaryReader(ifsLabels);
  int magic1 = brImages.ReadInt32(); // обратный порядок байтов
  magic1 = ReverseBytes(magic1); // преобразуем в формат Intel
  int imageCount = brImages.ReadInt32();
  imageCount = ReverseBytes(imageCount);
  int numRows = brImages.ReadInt32();
  numRows = ReverseBytes(numRows);
  int numCols = brImages.ReadInt32();
  numCols = ReverseBytes(numCols);
  int magic2 = brLabels.ReadInt32();
  magic2 = ReverseBytes(magic2);
  int numLabels = brLabels.ReadInt32();
  numLabels = ReverseBytes(numLabels);
  for (int di = 0; di < numImages; ++di)
  {
    for (int i = 0; i < 28; ++i) // получаем пиксельные
                                 // значения 28x28
   {
      for (int j = 0; j < 28; ++j) {
        byte b = brImages.ReadByte();
        pixels[i][j] = b;
      }
    }
    byte lbl = brLabels.ReadByte(); // получаем маркеры
    DigitImage dImage = new DigitImage(28, 28, pixels, lbl);
    result[di] = dImage;
  } // по каждому изображению
  ifsPixels.Close(); brImages.Close();
  ifsLabels.Close(); brLabels.Close();
  return result;
}

Формат файла обучающих пиксельных данных MNIST имеет начальное магическое целочисленное значение (32 бита), равное 2051, за которым следует количество изображений (целочисленное значение), затем количество строк и количество столбцов (целочисленные значения), потом 60 000 изображений по 28 × 28 пикселей = 47 040 000 байтовых значений. Поэтому после открытия двоичных файлов первые четыре целочисленных значения считываются методом ReadInt32. Например, количество изображений считывается так:

int imageCount = brImages.ReadInt32();
imageCount = ReverseBytes(imageCount);

Любопытно, что файлы MNIST хранят целочисленные значение в «тупоконечном» формате (big endian format) (с обратным порядком байтов), используемым некоторыми процессорами, отличными от Intel, а не в более привычном «остроконечном» формате (little endian format), наиболее распространенном на аппаратном обеспечении, на котором работает программное обеспечение Microsoft. Поэтому, если вы используете обычное оборудование в стиле ПК, для просмотра или использования любого из этих целочисленных значений нужно сначала выполнить преобразование из «тупоконечного» в «остроконечный» формат. Под этим подразумевается смена порядка четырех байтов, образующих целое значение. Например, магическое число 2051 в «тупоконечной» форме:

00000011 00001000 00000000 00000000

И то же самое, но в привычной «остроконечной» форме:

00000000 00000000 00001000 00000011

Заметьте, что переставить в обратном порядке надо четыре байта, а не всю последовательность из 32 бит. Способов сделать это много. Я воспользовался высокоуровневым подходом на основе .NET-класса BitConverter, отказавшись от низкоуровневого подхода с манипуляциями над битами:

public static int ReverseBytes(int v)
{
  byte[] intAsBytes = BitConverter.GetBytes(v);
  Array.Reverse(intAsBytes);
  return BitConverter.ToInt32(intAsBytes, 0);
}

Метод LoadData считывает, но не использует заголовочную информацию. Вероятно, вы захотите проверить эти четыре значения (2051, 60000, 28, 28), чтобы убедиться, что файл не поврежден. После открытия обоих файлов и чтения целых чисел из заголовка LoadData считывает 28 * 28 = 784 последовательных пиксельных значений из файла с пиксельными данными и сохраняет их, а затем считывает одно маркерное значение из файла с маркерными данными и объединяет его с пиксельными значениями в объект DigitImage, который потом сохраняется в массив trainData, видимый на уровне класса. Обратите внимание на отсутствие явного идентификатора изображения. У каждого изображения есть неявный идентификатор индекса (index ID), который указывает позицию изображения (с отсчетом от 0) в последовательности изображений.

Показ изображения

Дважды щелкнув элемент управления button2, я регистрирую его обработчик событий. Код для вывода изображения приведен на рис. 5.

Рис. 5. Показ изображения MNIST

private void button2_Click(object sender, EventArgs e)
{
  // Отображаем следующее изображение
  int nextIndex = int.Parse(textBox4.Text);
  DigitImage currImage = trainImages[nextIndex];
  int mag = int.Parse(comboBox1.SelectedItem.ToString());
  Bitmap bitMap = MakeBitmap(currImage, mag);
  pictureBox1.Image = bitMap;
  string pixelVals = PixelValues(currImage);
  textBox5.Text = pixelVals;
  textBox3.Text = textBox4.Text; // обновляем текущий индекс
  textBox4.Text = (nextIndex + 1).ToString(); 
  listBox1.Items.Add("Curr image index = " +
    textBox3.Text + " label = " + currImage.label);
}

Индекс отображаемого изображения берется из textBox4 (индекс следующего изображения), затем ссылка на изображение извлекается из массива trainImage. Возможно, прежде чем пытаться обращаться к изображению, вы захотите добавить проверку, чтобы быть уверенным в том, что данные изображения были загружены в память. Изображение показывается в двух видах: в визуальной форме в элементе управления PictureBox и как шестнадцатеричные значения в элементе управления TextBox большого размера. Свойство Image элемента управления PictureBox может принимать объект Bitmap и выполнять его рендеринг. Замечательно! Объект Bitmap можно рассматривать фактически как изображение. Заметьте, что в .NET есть класс Image, но это абстрактный базовый класс, используемый для определения класса Bitmap. Поэтому ключ к выводу изображения — генерация объекта Bitmap из определенного в программе объекта DigitImage. Это выполняется вспомогательным методом MakeBitmap (рис. 6).

Рис. 6. Метод MakeBitmap

public static Bitmap MakeBitmap(DigitImage dImage, int mag)
{
  int width = dImage.width * mag;
  int height = dImage.height * mag;
  Bitmap result = new Bitmap(width, height);
  Graphics gr = Graphics.FromImage(result);
  for (int i = 0; i < dImage.height; ++i)
  {
    for (int j = 0; j < dImage.width; ++j)
    {
      int pixelColor = 255 - dImage.pixels[i][j]; // Черные цифры
      Color c = Color.FromArgb(pixelColor, pixelColor, pixelColor);
      SolidBrush sb = new SolidBrush(c);
      gr.FillRectangle(sb, j * mag, i * mag, mag, mag);
    }
  }
  return result;
}

Метод короткий, но не простой. Конструктор Bitmap принимает ширину и высоту как целочисленные значения, которые для базовых данных MNIST всегда равны 28 и 28. Если коэффициент масштабирования равен 3, изображение Bitmap будет размером (28 * 3) × (28 * 3) = 84 × 84 пикселя, и каждый квадрат 3 × 3 в Bitmap будет представлять один пиксел исходного изображения.

Передача значений для объекта Bitmap осуществляется неявно, через объект Graphics. Во вложенном цикле значение текущего пикселя дополняется значением 255, чтобы конечное изображение было черной или серой цифрой на белом фоне. Без такого дополнения изображение было бы белой или серой цифрой на черном фоне. Для создания оттенков серого те же значения для параметров red, green и blue передаются методу FromArgb. Альтернатива — передавать значения пикселя только одному из RGB-параметров, чтобы получить цветное изображение (оттенки красного, зеленого или синего) вместо черно-белого с оттенками серого.

Метод FillRectangle рисует область для объекта Bitmap. Первый параметр — цвет. Второй и третий — x- и y-координаты верхнего левого угла прямоугольника. Заметьте, что значения x увеличиваются сверху вниз, что соответствует индексу j в пиксельной матрице исходного изображения. Четвертый и пятый параметры метода FillRectangle являются шириной и высотой прямоугольной области для рисования, начиная от угла, указанного вторым и третьим параметрами.

Например, текущий пиксел, который надо показать, находится по i = 2 и j = 5 в исходном изображении и имеет value = 200 (представляет темно-серый цвет). Если коэффициент масштабирования установлен в 3, объект Bitmap будет изображением с размером 84 × 84 пикселя. Метод FillRectangle начнет рисовать с x = (5 * 3) = столбец 15 и y = (2 * 3) = строка 6 в Bitmap и создаст прямоугольник 3 × 3 пикселя с темно-серым цветом — (55,55,55).

Отображение значений пикселей изображения

Если вы вернетесь к коду на рис. 5, то заметите, что вспомогательный метод PixelValues используется для генерации шестнадцатеричного представления значений пикселей изображения. Этот метод короткий и простой:

public static string PixelValues(DigitImage dImage)
{
  string s = "";
  for (int i = 0; i < dImage.height; ++i) {
    for (int j = 0; j < dImage.width; ++j) {
      s += dImage.pixels[i][j].ToString("X2") + " ";
    }
    s += Environment.NewLine;
  }
  return s;
}

Он конструирует одну длинную строку со встроенными символами перевода каретки, используя конкатенацию строк для простоты. Когда эта строка помещается в элемент управления TextBox, свойство Multiline которого установлено в True, строка будет показываться, как на рис. 1. Хотя шестнадцатеричные значения могут оказаться труднее в интерпретации, чем десятичные, они форматируются корректнее..

Куда двигаться дальше?

Распознавание изображений (IR) — задача концептуально простая, но крайне трудная в реализации на практике. Первый шаг в понимании IR — визуализация общеизвестного набора данных MNIST, как показано в этой статье. Если вы взглянете на рис. 1, то увидите, что изображение MNIST — это на самом деле не что иное, как 784 значения с сопоставленным маркером, например «4». Поэтому распознавание изображения сводится к поиску некоей функции, которая принимает 784 значения как ввод и возвращает (как вывод) 10 сходимостей по вероятностям, представляющих возможности того, что ввод — это цифры от 0 до 9 соответственно.

Распространенный подход к IR — применение той или ной формы нейронной сети. Например, вы могли бы создать нейронную сеть с 784 входными узлами, скрытым уровнем из 1000 узлов и выходным уровнем с 10 узлами. В такой сети нужно было бы определить итого (784 * 1000) + (1000 * 10) + (1000 + 10) = 795 010 весовых значений и смещений. Даже при наличии 60 000 обучающих изображений это было бы крайне трудной задачей. Но есть несколько потрясающий методологий, помогающих получить хорошее средство распознавания изображений. Эти методологии включают использование сверточной нейронной сети (convolutional neural network) и генерацию дополнительных обучающих изображений с применением упругой деформации (elastic distortion).


Джеймс Маккафри (Dr. James McCaffrey) — работает на Microsoft Research в Редмонде (штат Вашингтон). Принимал участие в создании нескольких продуктов Microsoft, в том числе Internet Explorer и Bing. С ним можно связаться по адресу jammc@microsoft.com.

Выражаю благодарность за рецензирование статьи экспертам Microsoft Research Волфу Кинцле (Wolf Kienzle).