Как написать программу машинного обучения на C++ с нуля

Машинное обучение — это одна из тех тем, по которым вы можете найти в Google простое объяснение того, что это такое, или научную статью, написанную кем-то с пятью докторами наук в области статистики и компьютерных наук. em> и ничего между ними.Мир, в котором мы живем сегодня, любит это модное слово, но что это значит, чтобы сделать что-то с нуля на языке, отличном от Python. Я решил создать проект на С++, в котором я хотел создать классификатор рукописных цифр с использованием алгоритма K-ближайших соседей, алгоритм, который я нашел довольно интуитивно понятным и простым для понимания.

Примечание. Этот проект зависит от внешней библиотеки Eigen.

С чего начать?

Ну, согласно 7 шагам машинного обучения, первое, что нам нужно, это данные! Итак, для этого проекта мы будем использовать набор данных MNIST. На сайте есть четыре файла, которые мы собираемся использовать: файлы train и файлы t10k. Учебные файлы содержат так называемый наш набор обучающих данных с 60 000 изображений рукописных цифр, а файлы t10k содержат наш тестовый набор данных с 10 000 изображений рукописных цифр, которые отличаются от 60 000 в наборе обучающих данных.

Вы заметите, что у нас есть два файла, которые похожи друг на друга как для обучающего, так и для тестового набора данных, это так называемые файлы меток, это просто значения в диапазоне от 0 до 9, представляющие метку. для соответствующего изображения в файле изображений. Как вы могли заметить, пытаясь открыть эти файлы, они содержат то, что кажется тарабарщиной, но что действительно здорово, так это то, что вы смотрите на данные, закодированные в чистом байте! Файлы хранятся в формате idx, который является предпочтительным форматом для хранения матриц и других числовых данных. Нам нужно будет написать алгоритм для анализа этих файлов, чтобы получить из них какие-то значимые данные. Итак, мы переходим к следующему этапу процесса машинного обучения Подготовка данных.

Подготовка данных

Теперь мы наконец можем приступить к программированию. Проект, который я написал, будет написан на c++14, так как я чувствую, что он даст понимание на достаточно низком уровне, но с нужной степенью абстракции, чтобы все по-прежнему имело смысл.

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

public:
void prepareData(const std::string& image_file_path, 
const std::string& label_file_path){
    read_Label_File(label_file_path);
    read_Image_File(image_file_path);
}
private:
void read_Image_File(const std::string& image_file_path);
void read_Label_File(const std::string& label_file_path);

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

Итак, присмотревшись к функции read_Image_File, мы видим, что она принимает строковый параметр image_file_path, который представляет собой просто абсолютный путь к загруженному нами файлу MNIST. Первое, что мы собираемся сделать, это создать объект ifstream, представляющий наш файл:

std::ifstream image_file(image_file_path);
if(!image_file.is_open()){ // Ensure that it has found the file
    std::cerr << "Could not open image file\n";
    return;
}

Теперь самое сложное: на веб-сайте мы видим, как данные представлены в файле изображения MNIST.

[offset] [type]          [value]          [description] 
0000     32 bit integer  0x00000803(2051) magic number 
0004     32 bit integer  60000            number of images 
0008     32 bit integer  28               number of rows 
0012     32 bit integer  28               number of columns 
0016     unsigned byte   ??               pixel 
0017     unsigned byte   ??               pixel 
........ 
xxxx     unsigned byte   ??               pixel

Это может показаться странным, но на самом деле все очень просто и интуитивно понятно. Глядя на первую строку в таблице, мы видим 0000, указывающую, что мы находимся в начале файла, тип данных, которые мы ожидаем найти в этой точке файла, представляет собой 32-битное целое число с шестнадцатеричное значение 0x00000803 или десятичное значение 2051. Это так называемое магическое число, которое можно рассматривать как подпись, указывающую тип файла. вторая строка в таблице имеет смещение 0004, указывающее, что мы переместили 4 байта дальше в файл, что имеет смысл, поскольку магическое число было 4-байтовым фрагментом данных. Мы снова ожидаем найти 32-битное целое число со значением 60000, которое указывает количество изображений в файле. Переходя к следующим двум строкам, мы ожидаем найти размеры изображения, которое мы собираемся прочитать. Теперь, после прочтения этих метаданных, мы можем перейти к чтению значений пикселей изображений, которые имеют тип байтов без знака, это связано с тем, что изображения имеют оттенки серого, и это означает, что пиксели будут иметь значение от 0 до 255. это диапазон, который может представлять беззнаковое 8-битное (1 байт) значение.

Теперь, чтобы прочитать определенное количество байтов за раз из файла, мы будем использовать метод read из объекта ifstream. Метод чтения принимает два параметра: указатель на переменную, в которой будут храниться данные, и количество байтов, которое нужно прочитать из файла. Сначала мы начнем с чтения данных из файла этикетки.

std::ifstream label_file(label_file_path);
if(!label_file.is_open()){ // Check if the file is found
    std::cerr << "Could not open label file\n";
    return;
}
int magic_number_label = 0; // 32 bit int (4 bytes)
int number_of_labels = 0;
label_file.read((char*)&magic_number_label,sizeof(magic_number_label));
magic_number_label = reverseInt(magic_number_label); 
label_file.read((char*)&number_of_labels,sizeof(number_of_labels));
number_of_labels = reverseInt(number_of_labels); // Will explain this later

Теперь, когда у нас есть метаданные файла, мы можем начать чтение фактических данных этикетки:

std::vector<unsigned char> labels;
for(int i = 0; i < number_of_labels; i++){
   label_file.read((char*)&label,sizeof(label));
   labels[i] = label;
}

Чтение метаданных из файла изображения аналогично:

std::ifstream image_file(image_file_path);
if(!image_file.is_open()){ // Ensure that it has found the file
    std::cerr << "Could not open image file\n";
    return;
}
image_file.read((char*)&magic_number_image,sizeof(magic_number_image));
magic_number_image = reverseInt(magic_number_image);
image_file.read((char*)&number_of_images,sizeof(number_of_images));
number_of_images = reverseInt(number_of_images);
image_file.read((char*)&number_of_rows,sizeof(number_of_rows));
number_of_rows = reverseInt(number_of_rows);
image_file.read((char*)&number_of_cols,sizeof(number_of_cols));
number_of_cols = reverseInt(number_of_cols);

Возможно, вы заметили использование второго метода, и это функция reverseInt, логика функции не важна, она используется для преобразования значения unsigned char обратно в целочисленное значение со знаком и реализовано следующим образом:

int reverseInt (int i){
            unsigned char c1=0, c2=0, c3=0, c4=0;
            c1 = i & 255;
            c2 = (i >> 8) & 255;
            c3 = (i >> 16) & 255;
            c4 = (i >> 24) & 255;
            return ((int)c1 << 24) + ((int)c2 << 16) + ((int)c3 << 8) + c4;
};

Теперь, прежде чем мы прочитаем данные пикселей, мы собираемся создать класс Image просто для упрощения. Мы собираемся использовать собственные матрицы для хранения данных пикселей в виде целочисленных значений, чтобы упростить математические операции, но вы можете использовать любую структуру данных по выбору, это просто упрощает работу, когда мы реализуем алгоритм ML.

class Image{
public:
        static const int rows = 28;
        static const int cols = 28;
Image(std::vector<std::vector<unsigned char>>,unsigned char);
        Image();
        ~Image();
        Eigen::Matrix<int,rows,cols> getPixelData() const;
        int getLabel() const;
private:
        Eigen::Matrix<int,rows,cols> pixels;
        int label;
};
Image::Image(){}
Image::Image(std::vector<std::vector<unsigned char>> pixels, unsigned char label){
    for(int i = 0; i < rows; i++){
        for(int j = 0; j < cols; j++){
            // The () operator has been overloaded in the Eigen library to access specific elements
            this->pixels(i,j) = (int)pixels[i][j];
        }
    }
    this->label = (int)label;
}
Image::~Image(){}
Eigen::Matrix<int,28,28> Image::getPixelData() const{
    return this->pixels;
}
int Image::getLabel() const{
    return this->label;
}

Теперь мы можем начать считывать данные пикселей из файлов набора обучающих данных.

for (int num = 0; num< number_of_images; num++) {
      // Creating a 28x28 vector to store pixel values
      std::vector<std::vector<unsigned char>> pixels(number_of_rows,std::vector<unsigned char>(number_of_cols));
      // Looping through file to construct 28x28 pixel matrix
      for (int j = 0; j < number_of_rows; j++) {
        for (int k = 0; k < number_of_cols; k++) {
            unsigned char temp;
            image_file.read((char*)&temp,sizeof(temp));
            pixels[j][k] = temp;
        }
      }
      // Construct Image object with pixels and corresponding label
      this->image_data[num] = Image(pixels,labels[num]);
}

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

Дизайн модели

Как упоминалось ранее, мы собираемся использовать алгоритм K-Nearest-Neighbor. Это контролируемый алгоритм машинного обучения, который можно использовать для классификации или регрессии. Обучение с учителем относится к тому факту, что мы обучаем алгоритм с примерами данных. Это идеально подходит для нашего случая, так как мы хотим, чтобы он классифицировал рукописные цифры. Предполагается, что подобные вещи существуют в непосредственной близости друг от друга, в нашем случае это означает, что цифры, принадлежащие к одной и той же классификации цифр, будут иметь похожий вид, если говорить прямо. Мы собираемся объявить следующую функцию:

// Choose k to be 10 because there are 10 possible digits 0-9
bool Classify(std::vector<Image> data, Image query, int k=10);

Таким образом, первым шагом в алгоритме после подготовки данных является расчет расстояния, мы собираемся использовать формулу Евклида, которая выглядит примерно так:

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

std::vector<std::pair<int, int>> dist_and_k;
for (int i = 0; i < data.size(); i++) {
        dist_and_k.push_back(
        std::make_pair(
          (data[i].getPixelData()
           -query.getPixelData()).array().pow(2).sum(),
           data[i].getLabel())
        );
}

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

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

sort(dist_and_k.begin(), dist_and_k.end(),
           [](std::pair<int, int> p, std::pair<int, int> p2) {
             return p.first < p2.first; 
}); 

Мы используем алгоритм сортировки и передаем ему лямбда-функцию для сортировки вектора пар в соответствии с их расстоянием.

Теперь мы сохраняем метки верхних k меток из отсортированного вектора (помните, что k было дано нам в качестве параметра).

std::vector<int> k_labels(k); // Create vector of size k
for (int i = 0; i < k; i++) {
   k_labels[i] = dist_and_k[i].second; // Only store the labels in vector
}

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

#include <algorithm> 
int mode(std::vector<int> v) {
int maxCount = 1;
int mode = v[0]; // Init mode value to first label
for_each(v.begin(), v.end(), [&](int i) {
       int d = std::count(v.begin(), v.end(), i); // Count each value
       if (d > maxCount) { // See if count is greater than max
         maxCount = d;
         mode = i; // Update mode to new label
       }
  });
  return mode; 
}

Приведенная выше функция просто ищет метку, которая является наиболее распространенной из k выбранных нами меток.

Теперь мы можем добавить следующее выражение в конец функции Classify.

return mode(k_labels)==query.getLabel();

Который оценивается как истина, если наше предположение соответствует метке, присвоенной функции.

И это все, теперь мы можем видеть, насколько точен наш алгоритм.

Оценка

dh.prepareData("path_to_images", "path_to_labels"); // Get image data
// Read test data, you can implement your own function for this
double count = 0;
double iterNum = 10;
for(int i = 0; i < iterNum; i++){
     bool eval =   Classify(dh.get_Image_Data(),dh.get_Test_Image_Data([i],10);
     if(eval) count++;
}
cout << "Accuracy: " << (count/(iterNum)*100) << "%\n";
// Output Example: Accuracy: 97.4%

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

Где сейчас?

Что ж, теперь вы можете немного повеселиться, поэкспериментировать со значением k, чтобы увидеть, как оно влияет на результат, или посмотреть, как изменится точность при обработке больших фрагментов данных, настроив нашу переменную iterNum. Существуют способы повышения эффективности программы путем экспериментов с различными функциями расчета расстояний. Если вы хотите узнать больше, посмотрите эту академическую статью. Это был очень простой проект, но я думаю, что это хорошая отправная точка, возможно, для добавления различных алгоритмов и, возможно, для создания собственной библиотеки ML, но надеюсь, вам понравилось! Полный проект находится на моем Github по ссылке ниже.

Полный проект