Привет, Хабр! Меня зовут Кирилл Колодяжный, я ведущий инженер-программист в YADRO. Помимо основных рабочих задач, включающих исследование проблем производительности СХД, я увлекаюсь машинным обучением. Участвовал в коммерческих проектах, связанных с техническим зрением, 3D-сканерами и обработкой фотографий. В задачах часто использовал С++, хотя машинное обучение традиционно ассоциируется с Python. Этот язык программирования буквально захватил сферу, его используют повсюду — от обучающих курсов до серьезных ML-проектов.

Однако Python — не единственный язык, на котором можно решать задачи машинного обучения. Так, альтернативой может стать С++. Если последний вам ближе, вам будет интересен и полезен этот текст.

Под катом разберемся:

  • как организовать работу с данными и загрузку обучающего датасета, 

  • как описать структуру нейронной сети, 

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

  • как организовать конвейер обучения сети, 

  • как использовать предобученные глубокие сети для решения задач. 

Изображение сгенерировала нейросеть
Изображение сгенерировала нейросеть

Где в машинном обучении применяется С++ 

Реализация базовых вычислительных алгоритмов

C++ часто используется в машинном обучении для реализации сложных алгоритмов. Он широко применяется в бэкенде популярных фреймворков, таких как PyTorch, TensorFlow и scikit-learn. Они, в свою очередь, основаны на математических библиотеках, реализующих стандарт BLAS — например, OpenBLAS или cuBLAS. Это позволяет строить высокоуровневые математические библиотеки, к которым относятся Eigen, Armadillo и ATen. NumPy, популярная библиотека Python для работы с математическими операциями, также использует C++ для реализации внутренних функций, обеспечивая быструю и эффективную работу с тензорами и матрицами.

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

PyTorch и TensorFlow предоставляют специальный API для расширения функционала. Допустим, вам нужно реализовать тензорную операцию, которая не представлена в фреймворке и у вас есть достаточно удобный механизм для ее реализации на C++. Почему на C++? Потому что, скорее всего, вам потребуется использовать CUDA или OpenCL для эффективной реализации, а они тоже используются в связке с С++.

Машинное обучение на конечных устройствах

Наиболее массово C++ для машинного обучения применяется, когда готовый продукт деплоится, например, на устройство видеоаналитики. Чаще всего там нет смысла разворачивать Python-среду и ставить интерпретатор — достаточно выполнить алгоритм в режиме вывода (inference). Поэтому существует достаточно большой спектр фреймворков, заточенных на адаптацию под микроконтроллеры и embedded-платформы.

Допустим, есть проект OpenVINO. Он специализируется на деплое машинного обучения на Intel-платформы. TensorRT — это платформа для конвертации нейронных сетей от NVIDIA, достаточно массово используются на платформах типа Jetson, с архитектурами Xavier или Orin. PyTorch и TensorFlow — популярные фреймворки — тоже предоставляют свои механизмы для запуска этих алгоритмов в рамках C++ на устройствах. Это TorchScript, torch.jit, и TensorFlow Lite, tflite micro. 

Хотите узнать, какие еще задачи решают разработчики на С++? Регистрируйтесь на бесплатный онлайн-митап YADRO по С++, который состоится 20 марта. Программа встречи — по ссылке. 

Решаем задачу поиска лиц с помощью С++ и его библиотек

Мы поговорим о том, как использовать C++ на самом высоком уровне и решить задачи, где обычно используют Python или технологии Julia и R. 

Задачу по поиску лиц можно разделить на два шага:

  1. Поиск регионов с лицами людей. Здесь воспользуемся готовым алгоритмом, доступным в библиотеке для машинного обучения: скользящим окном пройдемся по фотографии и классифицируем регионы как лицо или не лицо.

  2. Сравнение региона с лицом с искомым лицом. Для сравнения лиц реализуем свою нейронную сеть.

Этап 1. Ищем регионы с лицами людей

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

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

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

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

Скользящее окно
Скользящее окно

Таких направлений может быть много. Мы ограничимся определенными: вверх, вниз, вправо, влево и по диагонали.

Направления изменения цвета. Гистограмма частоты встречаемости.
Направления изменения цвета. Гистограмма частоты встречаемости.

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

Библиотека Dlib 

Загрузка изображений

Для решения задачи нужна библиотека с ограниченным набором функций, простой установкой и без лишних зависимостей. Будем использовать Dlib — известную библиотеку, которая предоставляет алгоритмы для компьютерного зрения и машинного обучения. Она легковесная, собирается и импортируется проще, чем ее аналоги — например, OpenCV.

Для использования детекции лиц на фотографии достаточно использовать два заголовочных файла. Это image/io/h и frontal/fact/detector.h, которые будут отвечать за предоставление функций загрузки изображений и детекции лиц.

#include <dlib/image/io.h>
#include <dlib/image/processing/frontal/face/detector.h>
...
using namespace dlib;

Изображения могут быть представлены двумя типами данных: двумерным массивом или матрицей. Для работы с цветными изображениями или с изображениями в оттенках серого нам достаточно специализировать шаблон array2d нужным типом. В коде ниже мы видим тип rgb_pixel. На самом деле это 32-битный integer, он позволяет описывать пространство цветов в схеме RGB. А использование типа unsigned char позволит работать с оттенками серого.

using image_t = array2d<unsigned char>;
using rgb_image_t = array2d<rgb_pixel>;
using matrix_t = matrix<float>;

Также нам потребуется матрица. Это математический примитив, класс которого может специализироваться нужным типом данных. Мы используем float. Тип matrix предоставляет больше специализированной функциональности, которая пригодится на этапе тренировки нейронной сети. 

Для загрузки изображений нам достаточно использовать функцию load_image из библиотеки Dlib. Организовано все просто: функция принимает путь к файлу изображения и сама определяет тип изображения — png, jpeg или bmp.

std::string image_file_path;
...
image_t gray_scale_image;
load_image(gray_scale_image, image_file_path);
 
rgb_image_t rgb_img;
load_image(rgb_img, image_file_path);

Пирамида масштабированных изображений

Следующий этап —  построение пирамиды масштабированных изображений с помощью функции pyramid_up

image_t gray_scale_image;
...
pyramid_up(gray_scale_image);

Существует и функция pyramid_down: можно построить пирамиду для уменьшения картинки. Мы видим, что функция принимает на вход один объект — изображение, которое нужно отмасштабировать. Остальные уровни масштабирования помещаются в то же изображение и присоединяются к основному друг за другом. Это сделано так, потому что вся остальная функциональность библиотеки понимает формат и использует его автоматически.

Детектор лиц

Для детекции лиц мы создаем объект типа face_detector с помощью функции get_frontal_face_detector

auto face_detector = get_frontal_face_detector();
...
auto face_rects = face_detector(gray_scale_image);

У формата есть перегруженный оператор функции, который принимает на вход изображение. Результатом является контейнер, который содержит прямоугольники — регионы с лицами. 

Использование get_frontal_face_detector накладывает ограничение: система работает только с лицами, снятыми в анфас. Однако библиотека предоставляет и функциональность, основанную на глубоких нейронных сетях, для детекции лиц в других положениях. 

Постобработка регионов с лицами

С помощью функции face_detector мы определили прямоугольники с лицами. Их необходимо вырезать, чтобы получить отдельные изображения. К сожалению, тип array2d не позволяет работать с частями изображения — для этого его нужно преобразовать в матрицу с помощью функции mat

image_t image;
rectange roi;
...
auto image_as_matrix = mat(image);
matrix<image_t::type> face_mat = subm(image_as_matrix, roi);

Если мы передадим двумерный массив, получим матрицу. А с помощью функции subm (submatrix) получим регион с лицом. Эта функция принимает два параметра: объект матрицы и размер прямоугольника, который нас интересует. 

Отложенные вычисления в C++

Зачастую в математических библиотеках для C++ и фреймворках машинного обучения некоторые операции, например, обрезка матрицы или умножение матрицы, не выполняются сразу. Вместо этого возвращается объект, описывающий данную операцию. Этот подход называется отложенным вычислением (lazy calculation). Результат операции вычисляется только тогда, когда он действительно нужен. Это позволяет библиотеке оптимизировать вычисления или вообще их не проводить, если результат не используется. Если требуется получить результат операции сразу, необходимо указать тип объекта результата, например, matrix<image_t::type>. При таком вызове выделится память для копирования изображения.

После того как мы вырезали прямоугольники с лицами, нужно привести их к стандартному размеру и формату. Для этого используем функцию resize_image. Она принимает объект исходного изображения и объект изображения нужного размера, куда будет записан результат масштабирования. Эта функция имеет дополнительные параметры, которые влияют на интерполяцию при увеличении (upsampling) или уменьшении (downsampling) изображения. Эти параметры определяют способ обработки и вычисления новых пикселей. В нашем случае используются параметры по умолчанию.

image_t image;
 
...
 
image_t scaled_image(FACE_SIZE, FACE_SIZE);
resize_image(image, scaled_image);

Далее нам нужно преобразовать целочисленные значения пикселей изображения в вещественные. Сначала используем функцию matrix_cast и приведем внутренний тип матрицы изображения к типу float. Также нужно, чтобы все значения пикселей были нормализованы — приведены к одному масштабу. Поскольку наша картинка в оттенках серого, пиксели принимают значения от 0 до 255. Чтобы привести их к общему масштабу от 0 до 1, делим на 255.

return matrix_cast<float>(mat(scaled_image))/255.0f;

Эти действия важны для следующего этапа — обучения нейронной сети.

Объединение всех частей кода для детекции лиц

auto face_detector = get_frontal_face_detector();
image_t img;
load_image(img, image_file_path);
pyramid_up(img);
auto face_rects = face_detector(img);
for (auto& face_rect : face_rects) {
  matrix<image_t::type> face_mat = subm(mat(img), face_rect);
  image_t scaled_face(FACE_SIZE, FACE_SIZE);
  resize_image(face_mat, scaled_face);
  auto face_imge = matrix_cast<float>(mat(scaled_face))/255.0f;
  ...
  // обработка изображения
}

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

Код получится компактным и выразительным, похожим на аналоги на Python.

Пример работы кода
Пример работы кода

Этап 2. Сравниваем регион с целевым лицом

Сиамские сети

Один из самых распространенных способов обучить нейронную сеть поиску похожих объектов — сиамские сети, тренированные с разными целевыми функциями, например, с функциями потерь Contrastive Loss или Triplet Loss. Такой подход также называют «изучением метрик» (metric learning), потому что сравнивать можно не только изображения, но и объекты из разных предметных областей. 

Сеть с двумя входами
Сеть с двумя входами

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

Contrastive Loss

Функция потерь Contrastive Loss основана на том, что мы максимизируем расстояние между разными классами векторов и минимизируем между одинаковыми. В формуле ниже d — это расстояние, а y принимает значения 0 или 1 в зависимости от того, одинаковые изображения или нет.  

Здесь d вычисляется как квадрат расстояния, что соответствует минимизации евклидового расстояния, описывающего два объекта. 

Triplet Loss

Подход с другой целевой функцией Triplet Loss чем-то похож: мы снова будем вычислять расстояние. У нас есть три сети с одной архитектурой и весами, через них проходит три изображения: якорное и два примера — позитивный и негативный.

Позитивный пример — это тот же самый объект, представленный иначе. В нашем случае сфотографированный под другим углом. Негативный пример — это пример другого объекта. 
Позитивный пример — это тот же самый объект, представленный иначе. В нашем случае сфотографированный под другим углом. Негативный пример — это пример другого объекта. 

Для примеров мы получаем три характеристических вектора (embbedings vectors), которые поступают на вход функции Triplet Loss. Здесь вычисляется два евклидовых расстояния между якорным изображением, позитивным и негативным примерами. 

Функция должна обучить сеть так, чтобы расстояние между якорным и позитивным примером было минимальное, а между якорным и негативным — максимальное. 

Результат обучения
Результат обучения

Датасет

Для обучения нейронных сетей нужны тренировочные данные — датасет. Мы используем доступный датасет Facial Recognition Dataset collected from Pinterest. Это набор из 105 фотографий знаменитостей с приблизительно одинаковым  масштабом, но разным ракурсом и освещением.

Датасет Facial Recognition Dataset collected from Pinterest
Датасет Facial Recognition Dataset collected from Pinterest

Обратим внимание, что наш модуль для определения лиц реализован только для лиц, снятых в фас. Значит, на некоторых фото найти лица не получится. Это приведет к несбалансированности датасета, и в последствии может стать одной из причин низкого качества сравнения лиц. В реальных задачах уделяйте достаточно внимания подготовке тренировочных и проверочных данных.

Применение библиотеки PyTorch для работы с тренировочными данными

Для реализации нашей задумки воспользуемся библиотекой PyTorch, которая представляет собой набор инструментов для машинного обучения. Важно отметить, что для C++ также существует LibTorch — библиотека, которая предоставляет С++ интерфейс для ядра PyTorch. LibTorch включает практически всю функциональность Python-версии и имеет схожий синтаксис. Если вы уже работали с PyTorch на Python, освоить LibTorch на C++ будет достаточно просто.

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

#include <torch/torch.h>
...
using namespace torch;

Начнем с построения конвейера обучения (training pipeline) нейронной сети. Первый шаг — работа с данными:

  • загрузка фотографий, 

  • приведение их к нужному размеру и формату,

  • следование стратегии, которую предписывает фреймворк PyTorch.

Для этого достаточно унаследоваться от класса Dataset и реализовать два метода: 

  • get возвращает один экземпляр данных для обучения, 

  • size возвращает, сколько всего у нас таких данных существует. 

class FaceDataset : public torch::data::datasets::Dataset<FaceDataset> {
  using Example = torch::data::Example<>;
 
 public:
 
  Example get(size_t index) override;
 
  torch::optional<size_t> size() const override;
};

Интерфейс для работы с тренировочными данными

Мы уже знаем, как загрузить изображение с помощью библиотеки Dlib и функции  load_image. Дальше нам необходимо преобразовать загруженную матрицу в тензор — тип данных в библиотеке PyTorch. Для создания тензора из матрицы Dlib используется функция from_blob, которая принимает на вход адрес в памяти, размер тензора и тип внутренних данных. 

std::vector<Tensor> face_tensors;
...
image_t img;
dlib::load_image(img, path);
auto face_rects = face_detector(img);
...
matrix_t face_mat = crop_scale_cast_face(img, face_rects[i]);
 
auto face_tensor = from_blob(&face_mat(0, 0),              
                             {1, face_mat.nr(), face_mat.nc()}, 
                             torch::kFloat);               
 
face_tensors.push_back(face_tensor.clone())

В приведенном примере используется функция crop_scale_cast_face. Она реализует ранее описанную нами функциональность для вырезания региона с лицом и его преобразование в нужный для обучения сети формат.

Чтобы выделить память под наш тензор и скопировать данные, мы вызываем метод clone у объекта face_tensor с типом torch::Tensor

Затем нужно реализовать проход по директории с фотографиями, их загрузку и преобразование в тензоры. А потом — доступ по индексу к каждому тензору, который представляет лицо в методе get. Следуя API Pytorch в реализации метода get нам нужно вернуть вернуть объект обучаемых данных класса Dataset::Example, так как наше класс унаследован от Dataset то в коде представленном ниже это FaceDataset::Example.

FaceDataset::Example FaceDataset::get(size_t index) {
  size_t positive_index;                    	
  size_t negative_index;                    	
  ...       	
  // объединение трех тензоров изображений в один тензор
  return {cat({
          	face_tensors[index],
          	face_tensors[positive_index],
          	face_tensors[negative_index]}),
          tensor({1.f, 1.f, 0.f},           	
               	torch::kFloat)
     	};
}

Чтобы использовать Triplet Loss в нашей функции обучения, необходимо объединить три изображения лиц в один тензор. Обычно объект Dataset::Example представляет собой пару тензоров — обучающие данные и их метки. Однако для Triplet Loss нам нужно объединить три тензора лиц. Используя функцию cat, мы объединяем их последовательно, добавляя одно дополнительное измерение к тензору. Также в качестве меток передаем тензор 1, 1, 0. Инициализация всех объектов довольно проста и напоминает использование функциональности в Python. Затем мы создаем объект типа FaceDataset, в конструктор которого передаем путь к директории с изображениями датасета.

std::string path;
...
// батчи будут организованы в vector<Tensor>
auto dataset = FaceDataset(path);
...
// батчи будут организованы в один Tensor
auto dataset = FaceDataset(path).map(data::transforms::Stack<>());

Здесь обратим внимание, как мы используем библиотеку PyTorch для реализации эффективного обучения нейронных сетей. Мы будем работать не с одним экземпляром обучаемых данных, а с набором (batch) — например, из 8, 32, 16 экземпляров. Это необходимо для эффективной утилизации вычислительных ресурсов и эффективной передачи данных из «оперативки» в память видеокарты. Поэтому PyTorch предоставляет для объектов типа Dataset метод map, который описывает, каким образом можно конкатенировать (объединять) экземпляры обучаемых данных.

В данном случае в методе map используется объект типа Stack — он обозначает, что батч наших данных будет объединен в один объект типа torch::Tensor. Если это не сделать, он будет представлен стандартным вектором в C++. Это не очень удобно, потому что потом придется вручную создавать тензор. 

Объекты типа Dataset используются совместно с объектами типа DataLoader. DataLoader реализует определенную стратегию работы с наборами данных для обучения. 

Каким образом создается объект DataLoader? В данном случае используем функцию make_data_loader, специализированную типом RandomSampler. Это значит, что данные из датасета будут выбираться случайным образом, с помощью  равномерного распределения. Это важно для нормального обучения нейронных сетей и других алгоритмов машинного обучения. 

auto train_loader = data::make_data_loader<data::samplers::RandomSampler>(
                               	std::move(face_dataset),
                               	data::DataLoaderOptions()
                               	   	.batch_size(batch_size)
                               	   	.workers(num_workers)
                	);

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

После того как мы создали датасет и завернули его в DataLoader, его использование становится тривиальным:

  1. С помощью обычного range-based for цикла проходимся по батчам. 

  2. На каждой итерации цикла получаем ссылку на конкретный батч. 

  3. С помощью полей батча получаем значения data и target

data — это наши тренировочные данные, один экземпляр которых представлен одним вектором. Но там может быть несколько векторов наших тренировочных данных, каждый вектор — это тройка объединенных изображений. Количество векторов мы описывали с помощью batch_size. A target — это тензоры с нашей разметкой, их количество тоже равно batch_size

for (auto& batch : *train_loader) {
  	auto merged_img = batch.data.to(device);
  	auto targets = batch.target.to(device);
 
  	// Разделяем изображение на 3 части
  	auto anchor_img = merged_img.narrow(1, 0, 1);
  	auto positive_img = merged_img.narrow(1, 1, 1);
  	auto negative_img = merged_img.narrow(1, 2, 1);
  	...
}

Если вспомним, когда мы реализовывали функцию get, мы объединили наши три фотографии в один тензор. Далее нам их нужно развернуть. Для этого понадобится функция narrow: мы используем индекс в первой размерности, для получения среза тензора, представляющего нужное изображение. Функциональность полностью совпадает с интерфейсом на Python.

Архитектура нейронной сети

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

Схема архитектуры сети. Число 128 в начале означает, что сеть будет выдавать одномерный вектор размерностью 128, который и будет характеризовать изображение.
Схема архитектуры сети. Число 128 в начале означает, что сеть будет выдавать одномерный вектор размерностью 128, который и будет характеризовать изображение.

Чтобы реализовать какой-то элемент нейронной сети или другого алгоритма машинного обучения в фреймворке библиотеки PyTorch, необходимо унаследоваться от класса Module — точно так же, как в Python. 

class FaceEmbeddingNetworkImpl : public torch::nn::Module {
 public:
  FaceEmbeddingNetworkImpl();
  torch::Tensor forward(torch::Tensor x);
 
 private:
  torch::nn::Sequential cnn_;
  torch::nn::Sequential fc_;
};
 
TORCH_MODULE(FaceEmbeddingNetwork);

Здесь важно использовать макрос TORCH_MODULE — он зарегистрирует этот модуль в системе автоградиента библиотеки PyTorch — и реализовать метод, который будет осуществлять прямой проход по нейронной сети или другому алгоритму. Кстати, forward в данном случае — это просто соглашение, а не виртуальная функция. Вместо него может быть любое другое название.

Сверточные и полносвязные слои

Мы разделим нейронную сеть на сверточные и полносвязные (линейные) слои и объединим их в контейнер Sequential. Sequential реализует набор методов, похожий на вектор C++, у него есть векторы push_back, которые последовательно заполняются слоями. 

cnn_ = Sequential();
 
cnn_->push_back(Conv2d(Conv2dOptions(1, 96, 11).stride(4)));
cnn_->push_back(Functional(torch::relu));
cnn_->push_back(MaxPool2d(MaxPool2dOptions(3).stride(2)));
 
cnn_->push_back(Conv2d(Conv2dOptions(96, 256, 5).stride(1)));
cnn_->push_back(Functional(torch::relu));
cnn_->push_back(MaxPool2d(MaxPool2dOptions(2).stride(2)));
 
cnn_->push_back(Conv2d(Conv2dOptions(256, 384, 3).stride(1)));
cnn_->push_back(Functional(torch::relu));
 
register_module("cnn", cnn_);

Сверточные слои

Мы добавляем в контейнер объект типа Conv2d для двумерной свертки, за которым следует объект, реализующий функцию активации ReLU, а затем операции пулинга. Этот процесс повторяется три раза, создавая слои с различными параметрами: размеры каналов и ядер свертки, операции пулинга, размер страйда. Мы используем контейнер Sequential для эффективного фьюзинга слоев в библиотеке PyTorch

Регистрация Sequential-контейнера с использованием метода register_module важна для автоградиентов.

fc_ = Sequential();
	
fc_->push_back(Linear(LinearOptions(384, 1024)));
fc_->push_back(Functional(torch::relu)); 
  
fc_->push_back(Linear(LinearOptions(1024, 256)));
fc_->push_back(Functional(torch::relu));
 
fc_->push_back(Linear(LinearOptions(256, EMBEDDIGS_SIZE)));
  
register_module("fc", fc_);

Полносвязные слои

Аналогично для линейных полносвязных слоев, мы также помещаем их в Sequential-контейнер с помощью методов pushback.

Хотя все это можно было сделать в одном контейнере Sequential, мы их разделили для наглядности. Создав слои, из которых состоит нейронная сеть, мы можем реализовать метод forward

Tensor FaceEmbeddingNetworkImpl::forward(Tensor x) {
  x = cnn_->forward(x);
  x = x.view({x.size(0), -1}); // Разворачиваем 3D тензор в 1D
  x = fc_->forward(x);
  return x;
}

В этом методе реализован прямой проход сети: мы программируем то, как входные данные проходят через слои в нужном нам порядке. Переход от сверточных слоев к полносвязным требует развертывания 3D-тензора в одномерный. Это достигается операцией view без дополнительного выделения памяти или копирования данных.

И этот метод возвращает уже преобразованные данные, в нашем случае — вектор, который характеризует входное изображение. 

Создание конвейера для обучения нейронной сети

Описав структуру нейронной сети, мы можем создать конвейер для ее обучения. Для этого сначала надо создать объект модели:  

FaceEmbeddingNetwork model_;

Так как класс модели унаследован от класса Module, у нас уже есть методы для переключения в различные режимы. Переключаем модель в режим тренировки с помощью метода train. Далее создаем объект оптимизатора для нейронной сети, в данном случае Adam. Обратите внимание: конструктор Adam принимает на вход model все параметры (веса) нашей сети

model_->train();
 
optim::Adam optimizer(model_->parameters());
 
nn::TripletMarginLoss triplet_loss(
                      	nn::TripletMarginLossOptions().margin(3.f));
 
for (size_t epoch = 0; epoch < num_epochs; ++epoch) {
  for (auto& batch : *train_loader) {
 
	// Подготовка батча данных
	
	// Вызов функций прямого распространения
 
	// Вычисление значения функции потерь 
 
	// Вызов функций обратного распространения ошибки
 
	// Обновление значений параметров
  }
}
save(model_, "weights.dat");

Теперь описываем нашу целевую функцию (функцию потерь) triplet_loss. Обычно целевые функции принимают дополнительные параметры, которые разработчик подбирает вручную или с использования автоматических техник. 

Тренировка состоит из двух циклов: первый проходит по эпохам, второй, «вложенный», — по батчам. Прохождение одной эпохи означает прохождение по всему датасету. Что включает в себя цикл: 

  1. Подготовку батча: разбиение изображения на три части.

  2. Пропуск изображений через нейронную сеть для получения трех характеристических векторов.

  3. Передачу векторов в функцию triplet_loss для вычисления значения функции потерь.

  4. Использование значения функции потерь и механизма автоградиента для вычисления всех градиентов.

  5. Обновление параметров сети с использованием оптимизатора.

После такой тренировки с помощью функции save, которая принимает имя файла и объект нейронной сети, мы можем сохранить снимок (snapshot) модели в  файл. 

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

auto anchor = model_->forward(anchor_img);
auto positive = model_->forward(positive_img
auto negative = model_->forward(negative_img);
 
auto loss_value = triplet_loss->forward(anchor, positive, negative);

После этого достаточно вызвать метод backward, который автоматически вычислит значение всех производных — результаты пригодятся для обновления параметров сети в методе step объекта optimizer. Также важно не забывать использовать метод zero_grad, чтобы очистить значения производных от предыдущих значений.

optimizer.zero_grad(); // Обнуление значений градиентов
loss_value.backward(); // Обратное распространение ошибки
optimizer.step();  	// Обновление параметров

Пример использования нейронной сети

Представим, что у нас есть метод get_embeddings, который принимает на вход изображение и выдает его характеристический вектор (embedding). По сути он подает его на вход нашей нейронной сети и получает как результат 128-размерный вектор. 

image_t target_face_img;
...
auto target_embeddings = get_embeddings(target_face_img);
...
image_t img;
...
auto face_rects = face_detector(img);
for (auto& face_rect : face_rects) {
  auto face_mat = crop_scale_cast_face(img, face_rect);
  auto face_embeddings = get_embeddings(face_mat);
  auto distance = length(target_embeddings - face_embeddings);
  if (distance < search_threshold) {
	// целевое лицо найдено 
  }
}

Как использовать эту функцию:

  1. При работе с объектом face_detector на целевом изображении с несколькими людьми мы получаем положение лиц в виде прямоугольников.

  2. Затем в цикле масштабируем их, приводим к нужному формату.

  3. Для каждого лица используем функцию get_embeddings, чтобы получить характеристический вектор.

  4. Вычисляем разницу между этими векторами и характеристическим вектором лица и находим норму этого вектора с помощью функции length из библиотеки Dlib, что, по сути, является евклидовым расстоянием между векторами. 

  5. Сравниваем это значение с пороговым значением search_treshhold

Если значение расстояния меньше порогового значения, мы считаем, что лица похожи.

При использовании моделей важно учитывать их состояния. Например, модель может находиться в состоянии тренировки (train), когда мы обучаем ее, или в состоянии вывода (inference или evaluation), которое мы получаем с помощью вызова метода eval.

Когда мы переводим модель в состояние eval, необходимо также указать PyTorch, что мы не хотим использовать автоградиент. Для этого создается объект типа NoGradGard, который действует в рамках текущего скоупа на C++. Это важно для оптимизации производительности, так как система автоградиента требовательна к ресурсам — как вычислительным, так и памяти.

Функция get_embeddings принимает на вход матрицу изображения из библиотеки Dlib. Эта матрица представляет собой результат загрузки изображения и преобразования его в матрицу с типом float для дальнейшей обработки.

embeddigs_t get_embeddings(matrix_t& face_img) {
  model_->eval();
  torch::NoGradGuard no_grad;
 
  auto face_tensor = matrix_to_tensor(face_img);
  face_tensor = face_tensor.to(kGPU);
 
  auto output = model_->forward(face_tensor);
  output = output.to(kCPU);
  const auto* data_ptr = output.const_data_ptr<float>();
 
  return dlib::mat(data_ptr, EMBEDDIGS_SIZE, 1);
}

Дальше мы преобразуем объект матрицы в формате PyTorch с помощью метода matrix_to_float, используя тот же подход, что и при создании класса FaceDataset. Затем перемещаем тензор в графический процессор (GPU) с помощью функции to, указывая тип устройства, куда нужно перенести данные. После этого передаем загруженный вектор в метод forward и получаем характеристический вектор. Мы можем перенести этот вектор на устройство CPU с помощью функции to, вернув его обратно в оперативную память. Наконец, мы преобразуем тензор библиотеки PyTorch обратно в тип данных матрицы Dlib, используя функцию mat, которая принимает указатель на память и размерность вектора.

Результаты обучения нейронной сети

Мы сравнивали лицо Арнольда Шварценеггера с лицами людей на фото. На картинке видно, что значение расстояния на мужских лицах находятся в одном диапазоне, а на женских и детских — в другом. Это значит, что нейросеть не научилась определять целевое лицо, но смогла отличить мужчин от женщин и детей.

Результаты обучения нейронной сети с помощью подхода, который я описал
Результаты обучения нейронной сети с помощью подхода, который я описал

Большую задачу, которую мы ставили в начале статьи — научить нейросеть искать лицо Арнольда на фото, мы не решили. Это связано с тем, что мы использовали маленький датасет, у нас была небольшая и простая по архитектуре нейросеть, мы не использовали специальные твики для обучения. 

Мы рассмотрели общий подход к использованию библиотеки PyTorch, в частности С++ API, реализованный в LibTorch. Мы создали нейронную сеть и настроили конвейер обучения. Этот подход можно применять не только для простых моделей, но и для более сложных и масштабных решений.

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

Та же задача, но другое решение: используем предтренированную нейронную сеть

Описание сети

Чтобы решить задачу поиска лиц, мы можем использовать нейросети, ранее обученные другими компаниями или инженерами. Мы воспользуемся сетью Inception Resnet (V1) — это большая сеть, натренированная на большем объеме данных, который называется VGGFace2. 

У нее другая архитектура, она намного сложнее ранее использованной сети. Убедитесь сами:

На этой схеме представлена самая верхняя структура этой сети. Каждый такой блок — еще одна нейронная сеть, они связаны друг с другом последовательно. 
На этой схеме представлена самая верхняя структура этой сети. Каждый такой блок — еще одна нейронная сеть, они связаны друг с другом последовательно. 
Здесь мы видим схему реализации блока верхнего уровня Stem. Он состоит из набора сверток и пулинга, связанных последовательно.
Здесь мы видим схему реализации блока верхнего уровня Stem. Он состоит из набора сверток и пулинга, связанных последовательно.
Это схемы блоков верхнего уровня Inception. Они представлены уже более сложной структурой с параллельным (skip-connection) прохождением данных в сети.
Это схемы блоков верхнего уровня Inception. Они представлены уже более сложной структурой с параллельным (skip-connection) прохождением данных в сети.
Это схемы Reduction блоков верхнего уровня. Они также представлены сложной структурой с разными путями прохождения данных.
Это схемы Reduction блоков верхнего уровня. Они также представлены сложной структурой с разными путями прохождения данных.

Как использовать предтренированную сеть

Для использования этой нейронной сети сначала нам необходимо обратиться к Python. Мы экспортируем сеть в формат, доступный для использования в C++. Для этого можем создать объект типа InceptionResnetV1 в Python, передавая в конструктор дополнительные параметры, такие как имя датасета, на котором сеть была обучена. Библиотека предоставляет различные варианты инициализации сети в зависимости от использованного датасета. Мы также вызываем метод eval, чтобы переключить сеть в режим вывода, а затем создаем произвольный тензор, который будет использоваться как пример входных данных, как мы видели ранее в C++.

import torch
from facenet_pytorch import InceptionResnetV1
 
resnet = InceptionResnetV1(pretrained='vggface2').eval()
 
# Пример входных данных для трассировки
example = torch.rand(1, 3, 100, 100)
 
traced_script_module = torch.jit.trace(resnet, example)
 
traced_script_module.save("face_net.pt")

Тут важно только сохранить размерность. В коде мы видим 1, 3, 100, 100 — это значит, что создаются три канала размером 100х100 пикселей. Для экспорта воспользуемся трейсингом для нейронных сетей. Это механизм, который реализован в PyTorch. Передав на вход сети этот пример входных данных, механизм трейсинга последовательно сохранит все вызванные операции, которые происходили в нейронной сети: свертки, функции активации и так далее. Результат трейса можно сохранить в файл, который можно будет загрузить как в C++, так и в Python.

Загрузка в C++ делается достаточно просто. Функция load, которую предоставляет нам заголовочный файл script.h, будет находиться в пространстве имен torch::jit::script. Как результат мы получим объект типа jit::script::Module, который похож по функциональности на модули из PyTorch. Знакомым способом мы можем его перевести на GPU с помощью функции to.

#include <torch/script.h>
...
using namespace torch::jit::script;
...
Module script_model_ = load(model_path_str);
script_model_.to(kGPU);

Как его дальше использовать? Загрузив этот модуль, нам необходимо подать ему на вход наше изображение, чтобы получить характеристический вектор для сравнения.  Рассмотрим реализацию функции get_embeddigs с использованием загруженного модуля сети.

embeddigs_t get_embeddings(matrix_t& face_img) {
  auto sch_face_tensor = matrix_to_tensor(face_img);
  auto face_tensor =
   	torch::cat({sch_face_tensor,
               	sch_face_tensor,
               	sch_face_tensor},
              	0);
  face_tensor.unsqueeze_(0);
  face_tensor = face_tensor.to(device);
  std::vector<torch::jit::IValue> inputs = {face_tensor};
  auto output = script_model_.forward(inputs).toTensor();
  ...
}

Функцию matrix_to_tensor мы разбирали, она преобразует матрицу Dlib в тензор библиотеки PyTorch. Далее воспользуемся функцией конкатенации, чтобы повторить тензор три раза.

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

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

После получения RGB-тензора face_tensor мы преобразуем его в значение torch::jit::IValue. Это соглашение фреймворка для трассировки в PyTorch. Передача этого значения в метод forward загруженного модуля — стандартный подход. Метод вернет объект типа torch::jit::IValue, который при необходимости можно преобразовать в тензор PyTorch и другие типы данных.

Результаты использования предтренированной сети

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

Результаты обучения предтренированной сети: значение для лица Аронольда — 0,6
Результаты обучения предтренированной сети: значение для лица Аронольда — 0,6

На следующей фотографии мы видим, что при сравнении характеристических векторов для людей, не похожих на Арнольда, мы получаем значение порядка единицы или выше. Вот этот подход уже можно использовать для сравнения лиц. Задав порог в 0,7, сеть может отличить Арнольда от лиц других людей.

Результаты сравнения целевого лица с остальными показали, что сеть сможет отличить Арнольда от других людей, если задать порог в 0,7
Результаты сравнения целевого лица с остальными показали, что сеть сможет отличить Арнольда от других людей, если задать порог в 0,7

Писать или не писать нейронную сеть на С++

Мое мнение — конечно, писать. В первую очередь я думаю о командах, которые хотят решать задачи, связанные с машинным обучением, но не имеют в штате Python-разработчика. Для них есть альтернатива. Еще мой способ может быть полезным для тех, кто уже написал код работы с данными на С++ и хочет использовать его в реализации или обучении нейросети — таким инженерам не придется переписывать код на Python или писать обертки.

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

Полезные материалы:

Комментарии (9)


  1. dreadL0rd
    19.03.2024 11:52

    Здравствуйте, а если увеличить количество разных направлений изменений от светлого к тёмному можно ли улучшить ответ и есть ли в этом реальный смысл?


    1. Mik42 Автор
      19.03.2024 11:52
      +1

      Здравствуйте, да думаю увеличение количества направлений может улучшить результат. Решение с HOG было в основном использовано для демонстрации одного из классических вариантов решения, сейчас можно использовать решения на базе NN которые будут более точными и функциональными, и возможно сравнимыми по производительности.


  1. Tyiler
    19.03.2024 11:52

    Приветствую.
    Спасибо за работу.

    > Писать или не писать нейронную сеть на С++

    С выводом не согласен только, и думаю не я один.

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

    Тоже есть опыт уже, когда-то писал свою обертку для использования нс в плюсах, для фр-ворка mxnet, позже и для tensorflow взялся. Заняло массу времени, главное спросить особо негде было (на so тоже ничего), только самому по коду (плюсовому) разбираться придется, потому что это не основной путь использования, а основной как раз на питоне.

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


    1. Kelbon
      19.03.2024 11:52
      +3

      Сколько у вас реально заняло времени и сил разобраться с плюсовым апи

      статья показывает что они буквально такие же (только типы указаны, а в питоне сам догадайся)

      только потом всеравно перепишут на питоне

      насколько я знаю наоборот пишут на питоне, а потом нейросети переписывают на С++, и не потому что на питоне быстрее, а потому что разработчик на питоне стоит меньше

      Тоже есть опыт уже, когда-то писал свою обертку для использования нс в плюсах,

      библиотеки питона это буквально уже обёртка над С++ кодом, а не наоборот


      1. Tyiler
        19.03.2024 11:52

        статья показывает что они

        да, что-то показывает, какое-то сходство есть, а много всего нет, в питоновской обертке не только ведь биндинг ф-ий, а скрытие массы бойлерпл-та, чтобы где-то одной строчкой обойтись и тд

        насколько я знаю наоборот пишут на питоне, а потом нейросети переписывают

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

        библиотеки питона это буквально уже обёртка над С++ кодом, а не наоборот

        уточню. Я писал обертку не для питона, а для исходной либы (которая на плюсах).
        Зачем писать обертку, если можно сразу использовать плюсовой интр-с?
        А его нельзя просто взять и использовать как на питоне, надо разбираться глубоко, и вся команда не будет этим заниматься. А использовать надо многим (проектов однотипных много, где переиспользуется код база), поэтому пишутся обертки, которые многое скрывают, чтобы в итоге торчали только нужные методы.

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

        Еще дополню. Зайдите на любой сайт этих фрейм-ов для нс, и увидите массу туториалов с картинками и примерами (на питоне все конечно), кучу форумов забитых ответами на все вопросы начинающих. Потом поищите "а как на плюсах использовать", увидите куцый пример в папке examples на гитхабе, и все на этом.


        1. Kelbon
          19.03.2024 11:52

           в питоновской обертке не только ведь биндинг ф-ий, а скрытие массы бойлерпл-та

          ...

          кучу форумов забитых ответами на все вопросы начинающих. Потом поищите "а как на плюсах использовать", увидите куцый пример в папке examples на гитхабе, и все на этом.

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


    1. hello_my_name_is_dany
      19.03.2024 11:52
      +1

      Не всегда имеется возможность использовать Python. Например, на iOS и Android это довольно непростая задача, интерпретатор весит немало, производительность будет так себе с такими прослойками. Тот же Tensorflow использует JNI на Android, а на iOS вообще можно статически слинковать. Если написать нейронную сеть на C++ в виде библиотеки, то в большинстве случаев её можно подключить к любому своему приложению: либо слинковать, либо через FFI


      1. Tyiler
        19.03.2024 11:52

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


  1. MAXH0
    19.03.2024 11:52
    +3

    Ха.. Интересен тест на таком фото