Обучение нейросетей на c++ в 2020

Еще 3 года назад существовало много способов обучить нейросеть на с++(caffe, tf1, mxnet, dlib). Сейчас все эти способы больше не существуют, не поддерживаются или считаются устаревшими. Сообществом принята такое разделение: обучение на питоне, инференс на с++.
[Мои причитания по этому поводу...]Сейчас если ты скажешь вслух: "Я хочу обучать нейросети на с++". На тебя смотрят как на сумасшедшего: "Вам нужно пересмотреть свои взгляды на жизнь..", "Вам не нужно обучать нейросети  на с++". Ситуация воспринималась бы мною как нормальная, если бы существовал естественный перевес в пользу питона. Но сейчас это больше похоже на запрет, чем на перевес. Я понял бы, если бы с++ фреймворки просто развивалиь медленнее остальных, но они буквально исчезают. Мой детектор фашизма бьет тревогу. Самое странное, что внутри всех современных фреймворков работает с++. Уверен, что существует много людей успешно разрабатывающих на с++ и интересующихся нейронными сетями. Уверен, что существует множество задач, где использование с++ для обучения нейросетей оправдано на 100%. Почему люди стали непринципиальными в важных вопросах и принципиальными в неважных?
Нужно отдать должное компании ABBYY, которая разработала и выложила в открытый доступ с++ фреймворк NeoML. Давайте же попробуем еще немного исправить ситуацию. Следующий have-fun-проект beGAN  я буду разрабатывать на с++ с использованием библиотеки глубокого обучения mxnet. Планируется опробовать технику генеративно-состязательных сетей. Сейчас же, для начала, обучим нейросеть на основе архитектуры resnet для классификации лиц. В mxnet есть c++ API в котором есть все что нужно для этого, но нет готового тренера. Я написал тренер, он очень сырой и не законченный, но умеет обучать нейросети методами sgd и adam с расписанием скорости обучения, сохранением и загрузкой весов сети, собрав по пути все грабли, которые только можно было собрать. Если интересует только код, то в самом конце статьи есть ссылка на репозиторий.
Но обо всем по порядку.Collapse )

Возможно, кому-то эта статья поможет сделать первый шаг в обучении нейросетей на с++ с использованием mxnet. Дальнейшие возможности практически беграничны: достаточно взглянуть на зоопарк моделей https://modelzoo.co/framework/mxnet.

Cсылка на коммит с кодом о котором речь в статье: https://github.com/DeliriumV01D/beGAN/commit/edd9644d6fcabb3a116720fc9560f2de3ab1302b

Имитация смаза (motion blur) в OpenCV

motion_blur.jpgДолгожданные выходные начались - поехали веселиться! Хорошая аугментация данных - половина обучения. В хорошей процедуре аугментации должны присутствовать случайные смещения, повороты, зашумление, отражения, смаз и расфокусировка, затенение части объекта и.т.д. Как легко замутить смаз (motion blur)? В большинстве случаев достаточно приблизить прямолинейным двухмерным перемещением. В первом приближении считаем скорость постоянной. Тогда функция перемещения будет просто наклонная прямая. Наклон определит направление смаза а длина его величину. Вычислить функцию перемещения она же ядро свертки нормированную на 1.
//Point spread function
void PSF(cv::Mat &outputImg, cv::Size filterSize, int len, double theta)
{
 cv::Mat h(filterSize, CV_32F, cv::Scalar(0));
 cv::Point point(filterSize.width / 2, filterSize.height / 2);
 cv::ellipse(h, point, cv::Size(0, cvRound(float(len) / 2.0)), 90.0 - theta, 0, 360, cv::Scalar(255), cv::FILLED);
 cv::Scalar summa = sum(h);
 outputImg = h / summa[0];
}

psf.jpg
Вычислим свертку изображения с этим ядром в пространстве частот пользуясь теоремой о свертке. Заранее расширим изображение, чтобы избежать краевых эффектов. Оказалось, для этого есть готовая функция filter2D.
  cv::Mat smpl, psf, mb;
  PSF(psf, cv::Size(20, 20), 20, 90);
  cv::filter2D(smpl, mb, CV_8U, psf);
                cv::imshow("mb", mb);


Я не сразу сообразил что это делается так просто. Поэтому когда решение нашлось сразу захотелось его зафиксировать здесь.

Глобальная оптимизация методом распределенной имитации отжига

Реализован распределенный алгоритм имитации отжига. Имитация отжига - универсальный и общий алгоритм предназначенный для поиска минимума функции. Возможно, когда-нибудь с помощью этого кода удастся решить какую-нибудь интересную физическую задачу, например открыть высокотемпературный сверхпроводник, смоделировать состояние вещества в нейтронной звезде, распределение электронных облаков в кристаллах или оптимизировать конфигурацию магнитов в термоядерном реакторе, ну а пока, в качестве примера, рассмотрен поиск решения задачи коммивояжера:) Так как сам алгоритм плохо параллелится и является случайным и стохастическим, то улучшения результата можно добиться выполнив его много раз и выбрав наилучшее найденное решение. С использованием этого факта и реализована его распределенная версия. Исходный код полностью открыт: https://github.com/DeliriumV01D/DistributedSimulatedAnnealing



Collapse )

Пул процессов MPI. Упрощение реализации распределенных алгоритмов.

Наконец, нашел в себе силы немного покреативить на каникулах. Цель разработки данной программы - создание общей основы для реализации распределенных алгоритмов, вписывающихся в рамки паттерна пул процессов. Нулевой процесс (управляющий) принимает задачи, ставит их в очередь и, по мере освобождения других процессов(рабочих) распределяет им эти задачи. Затем получает от них решения и хранит их в очереди решений.
Чтобы воспользоваться данным решением нужно определить свои классы задач и классы результатов, унаследовавшись от соответствующих интерфейсных классов (IMPITask и IMPIResult). Также нужно задать свою функцию(или функтор) вычисления результата и проинициализировать ими собственно пул процессов. Далее Используя методы AddTask добавлять задачи и GetResult чтобы получить результат. Вот содержимое файла main.cpp, где находится пример использования пула процессов MPI (класса TMPIProcessPool). В данном примере по вычислительным узлам распределяются и параллельно обрабатывается 35 заданий. Каждое имеет свой номер. Вычислительная функция очень простая: ожидает секунду и возвращает номер задания в качестве результата. Все результаты собираются и выводятся в консоль нулевым процессом.
[Spoiler (click to open)]
#include <iostream>
#include "tmpi_process_pool.h"
 
//Simple test of the MPI process pool with TestTask, TestResult and f
 
class TestTask : public IMPITask {
protected:
 int ID;
public:
 TestTask(const int id = 0) : IMPITask()
 {
  ID = id;
 }
 
 int GetID()
 {
  return ID;
 }
 
 size_t GetBufferSize()
 {
  return sizeof(int);
 }
 
 unsigned char * ToBuffer()
 {
  return (unsigned char *)&ID;
 }
 
 void FromBuffer(unsigned char * buffer)
 {
  ID = *(int *)buffer;
 };
};
 
class TestResult : public IMPIResult {
 int ID;
public:
 TestResult(const int id = 0) : IMPIResult()
 {
  ID = id;
 }
 
 int GetID()
 {
  return ID;
 }
 
 size_t GetBufferSize()
 {
  return sizeof(int);
 }
 
 unsigned char * ToBuffer()
 {
  return (unsigned char *)&ID;
 }
 
 void FromBuffer(unsigned char * buffer)
 {
  ID = *(int *)buffer;
 };
};
 
int main(int argc, char *argv[])
{
 //Process pool initializing with lambda function that gets task object and returns result object 
 auto f = [](TestTask *task)
 {
  std::this_thread::sleep_for(std::chrono::milliseconds(1000));
  return TestResult(task->GetID());
 };
 
 TMPIProcessPool <TestTask, TestResult> mpi_process_pool(f, 100);
 
 mpi_process_pool.Start();
 
 if (mpi_process_pool.GetMPIRank() == 0)
 {
  for (int i = 0; i < 35; i++)
   bool b = mpi_process_pool.AddTask(new TestTask(i));
 
  //It’s not necessary to wait until the end of the work, you can get the results as they received
  while (!mpi_process_pool.IsFinished())
   std::this_thread::sleep_for(std::chrono::milliseconds(1));
 
  IMPIResult ** result = new IMPIResult *;
  while (mpi_process_pool.GetResult(result))
  {
   std::cout<<"RESULT: "<<((TestResult*)(*result))->GetID()<<std::endl;
   delete (*result);
  }
  delete result;
 }
 //process #0 will stop when all answers are received, other processes will stop by command
 //mpi_process_pool.Stop();
}





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

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

Главное значение имеет функция Start() класса TMPIProcessPool. В зависимости от номера процесса она выполняет следующий действия. Все процессы, кроме нулевого ожидают в цикле прием длины пакета задания а затем и самого пакета задания парой вызовов MPI_Recv. Цикл прерывается и работа процесса завершается, если из нулевого процесса пришла нулевая длина пакета задания. Затем вызывается ResultFunc, это функция, которой инициализирован класс, принимающая объект-задание и возвращающая объект-результат. Затем парой вызовов MPI_Send в нулевой процесс передается сначала длина пакета результата, а следом и сам пакет. В нулевом процессе ожидается что задания поступают в очередь в основном потоке, поэтому запускается отдельный поток с лямбда-функцией. Этот поток в цикле забирает из потокобезопасной очереди TaskQueue задания и раздает их свободным процессам (MPI_Send). Затем происходит ожидание ответа от любого узла (MPI_Recv), полученные результаты помещаются в потокобезопасную очередь результатов. Поток завершит работу когда очередь заданий опустеет и все процессы завершат свою работу.


[Spoiler (click to open)]
void Start()
 {
  Finished = false;
  Stopped = false;
  Started = false;
  ProcessStates.resize(MPISize);
  for (auto it : ProcessStates)
   it = IDLE;
 
  if (MPIRank == 0)
  {
   //!!!Вынести из метода, можно оставить лямбдой
   auto f = [this]()
   {
    //Processing loop
    size_t buf_size = 0;
    MPI_Status status;
    IMPITask * task;
    do {
     //std::this_thread::sleep_for(std::chrono::microseconds(100)); ///!!!Переделать через conditions variable
     //Distribite tasks and receive results
     for (int i = 1/*!=0*/; i < ProcessStates.size(); i++)
      if (ProcessStates[i] == IDLE)
      {
       if (TaskQueue.Pop(task))
       {
        if (!Started)
         Started = true;
        //Send the task, first send a packet size
        buf_size = task->GetBufferSize();
        MPI_Send(&buf_size, sizeof(size_t), MPI_CHAR, i, 0, MPI_COMM_WORLD);
        MPI_Send(task->ToBuffer(), (int)buf_size, MPI_CHAR, i, 0, MPI_COMM_WORLD);
        delete task; //don't forget to free memory
        ProcessStates[i] = BUSY;
       } else {
        break;
       }
      }
 
     //All solutions are received = no tasks and no busy processes - stop calculations
     //!!!Не красиво, что надо обходить массив состояний, лучше завести отдельный set для занятых процессов, например
     bool NoBusyProcesses = true;
     for (int i = 1/*!=0*/; i < ProcessStates.size(); i++)
      if (ProcessStates[i] == BUSY)
      {
       NoBusyProcesses = false;
       break;
      }  
     if (Started && TaskQueue.Size() == 0 && NoBusyProcesses)
      Stopped = true;
 
     if (!Stopped && !NoBusyProcesses)
     {
      //If one package received from process(package size), wait for another package from this process
      //!!!В случае если узел отключется между этими двумя событиями программа зависнет
      //!!!Чтобы не загружать процессор в этом месте на 100% можно сделать через асинхронные MPI_IRecv?
      MPI_Recv(&buf_size, sizeof(size_t), MPI_CHAR, MPI_ANY_SOURCE, 0, MPI_COMM_WORLD, &status);
      std::vector<unsigned char> buffer(buf_size);
      MPI_Recv(buffer.data(), (int)buf_size, MPI_CHAR, status.MPI_SOURCE, 0, MPI_COMM_WORLD, &status);
      ProcessStates[status.MPI_SOURCE] = IDLE;
      IMPIResult * result = new TResult;
      result->FromBuffer(buffer.data());
      ResultQueue.Push(result);
     }  
    } while (!Stopped);
 
    //if flag stopped is on - send a stop command to the work processes
    //Finalization
    size_t sz = 0;
    for (int i = 1; i < MPISize; i++)
     MPI_Send(&sz, sizeof(size_t), MPI_CHAR, i, 0, MPI_COMM_WORLD);
    Finished = true;
   };  //lambda
   std::thread th(f);
   th.detach();
 
  } else { //(mpi_rank != 0)
   //Receive task or stop command(0 - size package), receive a package size first
   size_t buf_size = 0;
   MPI_Status status;
   while (!Stopped)
   {
    MPI_Recv(&buf_size, sizeof(size_t), MPI_CHAR, MPI_ANY_SOURCE, 0, MPI_COMM_WORLD, &status);
    if (buf_size != 0)
    {
     std::vector<unsigned char> buffer(buf_size);
     MPI_Recv(buffer.data(), (int)buf_size, MPI_CHAR, status.MPI_SOURCE, 0, MPI_COMM_WORLD, &status);
     //Solving the problem, geting a result
     TResult result;
     TTask task;
     task.FromBuffer(buffer.data());
     result = ResultFunc(&task);
     //Send a packege. First send pakege size.
     buf_size = result.GetBufferSize();
     MPI_Send(&buf_size, sizeof(size_t), MPI_CHAR, status.MPI_SOURCE, 0, MPI_COMM_WORLD);
     MPI_Send(result.ToBuffer(), (int)result.GetBufferSize(), MPI_CHAR, status.MPI_SOURCE, 0, MPI_COMM_WORLD);
    } else {
     Stopped = true;
    }
   }
   Finished = true;
  }
 }





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

Все исходники доступны на github'e:
https://github.com/DeliriumV01D/MPI-process-pool

Распознавание дорожных знаков

Эта программа была написана как тестовое задание при попытке трудоустройства в одну организацию. Сроки были очень маленькие (с учетом полной занятости на работе), поэтому делалось все в страшной спешке. Правильнее будет рассматривать код как демонстрационный, а не как попытку создать реально действующий алгоритм.
Итак, задание: разработать программу распознавания дорожных знаков в изображении. На входе - изображение. На выходе - ограничивающие прямоугольники дорожных знаков и идентификаторы их типов. Единственное, что я рискнул попробовать сделать - это простенький алгоритм распознавания дорожных знаков - классификатор дорожных знаков на сверточной нейронной сети, где фон(не дорожный знак) - это один из классов. Классификатор применяется к каждому фрагменту изображения методом скользящего окна(да, это медленно). Эффективнее было бы сделать отдельный каскадный детектор и классификатор, но за отведенное время я не успел бы обучить более одного алгоритма. По этой же причине я не мог попробовать SSD (Single Shot Detector) из каких-нибудь других библиотек машинного обучения(не dlib, потому что здесь таких алгоритмов, к сожалению, нет), а использовал только имеющиеся у меня наработки. Программа написана на c++ с использованием библиотек OpenCV, dlib. Обучение осуществлялось только на обучающей части базы German Traffic Sign Detection Benchmark. Ничего не успел толком протестировать. Зато, в этот раз выложил исходники целиком на github. Вот пример результата работы (присутствуют false positive ошибки):

Сжатие модели - очень интересная и простая идея, стоит попробовать

Из книги "Глубокое обучение" (Ян Гудфеллоу, Иошуа Бенджио, Аарон Курвилль). Сжатие модели (Bacilua te al., 2006). Основная идея - заменить исходную дорогостоящую модель другой, потребляющей меньше памяти и работающей быстрее. Сжатие модели применимо, когда размер исходной модели обусловлен в основном стремление предотвратить переобучение. В большинстве случаев модель с наименьшей ошибкой обобщения представляет собой ансамбль нескольких независимо обученных моделей. Вычислять предсказания всех n членов ансамбля дорого. Иногда даже одна модель обобщается лучше, если она велика (например, если применялась регуляризация прореживанием).
Такие большие модели обучают некоторую функцию f(x), но используют при этом гораздо больше параметров, чем необходимо для решения задачи. Их размер велик только потому что число обучающих примеров ограничено. После того как функция f(x) аппроксимирована, мы сможем сгенерировать обучающий набор, содержащий бесконечно много примеров, просто применив f к случайной выборке x. Затем мы обучаем новую, меньшую модель, так чтобы она совпадала с f(x) на этой выборке. 

Видео тестирование алгоритмов распознавания лиц и автомобильных номеров

Решил собрать все и протестировать "как есть" на данный момент. Вот что из этого вышло.
https://youtu.be/iPnIucrydo4

Пиксельная сегментация символов с помощью сверточной нейронной сети

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


 Давайте же спроектируем нейронную сеть и обучим ее строить такие маски!

Collapse )


  

Нелинейная ректификация изображения автомобильного номера, простейшая сегментация символов

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

Collapse )

Сегментация автомобильных номеров с помощью регрессионной модели на глубокой нейронной сети

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

Collapse )