Давайте повеселимся, реализовав модели глубокого обучения на C++.

Излишне говорить, насколько актуальны системы машинного обучения для исследований и промышленности. Благодаря их расширяемости и гибкости в настоящее время редко можно найти проект, в котором не используются Google TensorFlow или Meta PyTorch.

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

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

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

Проверьте другие истории:

1 — Кодирование 2D сверток на C++

2 — Функции стоимости с использованием лямбда-выражений

3 — Реализация градиентного спуска

4 — Активация функций

… еще не все.

Современные заголовки C++, <algorithm> и <numeric>

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

Ради нашей миссии здесь C++ включил удобный набор общих подпрограмм в заголовки <numeric> и <algorithm>. В качестве наглядного примера мы можем получить скалярный продукт двух векторов:

#include <numeric>
#include <iostream>

int main()
{
    std::vector<double> X {1., 2., 3., 4., 5., 6.};
    std::vector<double> Y {1., 1., 0., 1., 0., 1.};
 
    auto result = std::inner_product(X.begin(), X.end(), Y.begin(), 0.0);
    std::cout << "Inner product of X and Y is " << result << '\n';
    return 0;
}

и используйте такие функции, как accumulate и reduce, следующим образом:

std::vector<double> V {1., 2., 3., 4., 5.};

double sum = std::accumulate(V.begin(), V.end(), 0.0);

std::cout << "Summation of V is " << sum << '\n';

double product = std::accumulate(V.begin(), V.end(), 1.0, std::multiplies<double>());

std::cout << "Productory of V is " << product << '\n';

double reduction = std::reduce(V.begin(), V.end(), 1.0, std::multiplies<double>());

std::cout << "Reduction of V is " << reduction << '\n';

Заголовок algorithm содержит множество полезных процедур, таких как std::transform, std::for_each, std::count, std::unique, std::sort и так далее. Давайте посмотрим на наглядный пример:

#include <algorithm>
#include <iostream>

double square(double x) {return x * x;}

int main() 
{
    std::vector<double> X {1., 2., 3., 4., 5., 6.};
    std::vector<double> Y(X.size(), 0);
 
    std::transform(X.begin(), X.end(), Y.begin(), square);
    std::for_each(Y.begin(), Y.end(), [](double y){std::cout << y << " ";});
    std::cout << "\n";
    
    return 0;
}

Оказывается, в современном C++ вместо явного использования циклов for или while мы можем использовать такие функции, как std::transform, std::for_each, std::generate_n и т. д., передавая в качестве параметров функторы, лямбда-выражения или даже обычные функции.

Приведенные выше примеры можно найти в этом репозитории на GitHub.

Кстати, [](double v){...} — это лямбда. Теперь поговорим о функциональном программировании и лямбда-выражениях.

Функциональное программирование

C++ — это язык программирования с несколькими парадигмами, что означает, что мы можем использовать его для создания программ с использованием различных «стилей», таких как ООП, процедурный и, в последнее время, функциональный.

Поддержка C++ для функционального программирования начинается с заголовка <functional>:

#include <algorithm> // std::for_each 
#include <functional> // std::less, std::less_equal, std::greater, std::greater_equal
#include <iostream> // std::cout

int main() 
{

    std::vector<std::function<bool(double, double)>> comparators 
    {
        std::less<double>(), 
        std::less_equal<double>(), 
        std::greater<double>(), 
        std::greater_equal<double>()
    };

    double x = 10.;
    double y = 10.;
    auto compare = [&x, &y](const std::function<bool(double, double)> &comparator)
    {
            bool b = comparator(x, y);
            std::cout << (b?"TRUE": "FALSE") << "\n";
    };

    std::for_each(comparators.begin(), comparators.end(), compare);

    return 0;
}

Здесь мы используем std::function, std::less, std::less_equal, std::greater и std::greater_equal в качестве примера полиморфных вызовов в действии без использования указателей.

Как мы уже говорили, C++ 11 включает в себя изменения в ядре языка для поддержки функционального программирования. До сих пор мы видели один из них:

auto compare = [&x, &y](const std::function<bool(double, double)> &comparator)
{
    bool b = comparator(x, y);
    std::cout << (b?"TRUE": "FALSE") << "\n";
};

Этот код определяет лямбду, а лямбда определяет объект-функцию, то есть вызываемый объект.

Обратите внимание, что compare — это не имя лямбда-выражения, а имя переменной, которой присваивается лямбда-выражение. Действительно, лямбда-выражения — это анонимные объекты.

Эта лямбда состоит из 3 предложений: списка захвата ([&x, &y]), списка параметров (const std::function<boll(double, double)> &comparator) и тела (код между фигурными скобками{...}).

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

Лямбды очень полезны. Мы можем объявлять и передавать их как функторы старого стиля. Например, мы можем определить лямбду регуляризации L2:

auto L2 = [](const std::vector<double> &V)
{
    double p = 0.01;
    return std::inner_product(V.begin(), V.end(), V.begin(), 0.0) * p;
};

и передаем его обратно нашему слою в качестве параметра:

auto layer = new Layer::Dense();
layer.set_regularization(L2)

По умолчанию лямбда-выражения не вызывают побочных эффектов, т. е. не могут изменять состояние объектов во внешнем пространстве памяти. Однако мы можем определить лямбду mutable, если захотим. Рассмотрим следующую реализацию Импульса:

#include <algorithm>
#include <iostream>

using vector = std::vector<double>;

int main() 
{

    auto momentum_optimizer = [V = vector()](const vector &gradient) mutable 
    {
        if (V.empty()) V.resize(gradient.size(), 0.);
        std::transform(V.begin(), V.end(), gradient.begin(), V.begin(), [](double v, double dx) 
        {
            double beta = 0.7;
            return v = beta * v + dx; 
        });
        return V;
    };

    auto print = [](double d) { std::cout << d << " "; };

    const vector current_grads {1., 0., 1., 1., 0., 1.};
    for (int i = 0; i < 3; ++i) 
    {
        vector weight_update = momentum_optimizer(current_grads);
        std::for_each(weight_update.begin(), weight_update.end(), print);
        std::cout << "\n";
    }

    return 0;
}

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

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

Библиотека матриц и линейной алгебры

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

Матрицы и тензоры являются строительными блоками алгоритмов машинного обучения. В C++ нет встроенной реализации матрицы (и не должно быть). К счастью, есть несколько зрелых и отличных библиотек линейной алгебры, таких как Eigen и Armadillo.

Я с удовольствием использую Eigen в течение многих лет. Eigen (под лицензией Mozilla Public License 2.0) предназначен только для заголовков и не зависит ни от каких сторонних библиотек. Поэтому я буду использовать Eigen в качестве основы линейной алгебры для этой истории и далее.

Общие операции с матрицами

Важнейшей матричной операцией является поматричное умножение:

#include <iostream>
#include <Eigen/Dense>

int main(int, char **) 
{
    Eigen::MatrixXd A(2, 2);
    A(0, 0) = 2.;
    A(1, 0) = -2.;
    A(0, 1) = 3.;
    A(1, 1) = 1.;

    Eigen::MatrixXd B(2, 3);
    B(0, 0) = 1.;
    B(1, 0) = 1.;
    B(0, 1) = 2.;
    B(1, 1) = 2.;
    B(0, 2) = -1.;
    B(1, 2) = 1.;

    auto C = A * B;

    std::cout << "A:\n" << A << std::endl;
    std::cout << "B:\n" << B << std::endl;
    std::cout << "C:\n" << C << std::endl;

    return 0;
}

Обычно обозначаемая как mulmat, эта операция имеет вычислительную сложность O(N³). Поскольку mulmat широко используется в машинном обучении, на наши алгоритмы сильно влияет размер наших матриц.

Поговорим о другом типе матричного умножения. Иногда нам нужно только умножение матриц на коэффициенты:

auto D = B.cwiseProduct(C);
std::cout << "coefficient-wise multiplication is:\n" << D << std::endl;

Конечно, при умножении на коэффициенты размерность аргументов должна совпадать. Точно так же мы можем складывать или вычитать матрицы:

auto E = B + C;
std::cout << "The sum of B & C is:\n" << E << std::endl;

Наконец, давайте обсудим три очень важные матричные операции: transpose, inverse и determinant:

std::cout << "The transpose of B is:\n" << B.transpose() << std::endl;
std::cout << "The A inverse is:\n" << A.inverse() << std::endl;
std::cout << "The determinant of A is:\n" << A.determinant() << std::endl;

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

auto my_func = [](double x){return x * x;};
std::cout << A.unaryExpr(my_func) << std::endl;

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

Несколько слов о векторизации

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

for (int i = 0; i < 1024; i++) 
{
    A[i] = A[i] + B[i];
}

молча заменяется векторизованной версией:

for(i=0; i < 512; i += 2) 
{
    A[i] = A[i] + B[i];
    A[i + 1] = A[i + 1] + B[i + 1];
}

компилятором. Хитрость заключается в том, что инструкция A[i + 1] = A[i + 1] + B[i + 1] выполняется в то же время, что и инструкция A[i] = A[i] + B[i]. Это возможно, потому что две инструкции независимы друг от друга, а базовое оборудование имеет дублированные ресурсы, то есть два исполнительных устройства.

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

for(i=0; i < 256; i += 4) 
{
    A[i] = A[i] + B[i] ;
    A[i + 1] = A[i + 1] + B[i + 1];
    A[i + 2] = A[i + 2] + B[i + 2];
    A[i + 3] = A[i + 3] + B[i + 3];
}

Эта векторизованная версия позволяет программе работать в 4 раза быстрее по сравнению с исходной версией. Примечательно, что этот прирост производительности происходит без влияния на поведение исходной программы.

Несмотря на то, что векторизация выполняется компилятором, операционной системой и железом под деревом, мы должны быть внимательны при кодировании чтобы разрешить векторизацию:

  • Включение флагов векторизации, необходимых для компиляции программы
  • Граница цикла должна быть известна до начала цикла, динамически или статически.
  • Инструкции тела цикла не должны ссылаться на предыдущее состояние. Например, такие вещи, как A[i] = A[i — 1] + B[i], могут препятствовать векторизации, потому что в некоторых ситуациях компилятор не может безопасно определить, допустимо ли A[i-1] во время текущего вызова инструкции.
  • Тело цикла должно состоять из простого и прямолинейного кода. Также разрешены inline вызовы функций и ранее векторизованные функции. Но сложная логика, подпрограммы, вложенные циклы и вызовы функций в целом мешают векторизации работать.

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

Как правило, чем проще и проще код, тем более он подвержен векторизации. Следовательно, использование стандартных функций контейнеров <numeric>, algorithm, functional и STL указывает на код, который с большей вероятностью будет векторизован.

Векторизация в машинном обучении

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

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

Вместо выполнения 6 скалярных произведений между каждым из шестиXiвекторов и одним Vвектором для получения 6 выходов Y0, Y1 и т. д. мы можем сложить входные векторы, чтобы смонтировать матрицу M с шестью строками и выполнить ее один раз, используя одно mulmat умножение Y = M*V .

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

Заключение и следующие шаги

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

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

В следующей истории мы научимся кодировать 2D Convolution, самую фундаментальную операцию глубокого обучения.

Благодарность

Я хотел бы поблагодарить Эндрю Джонсон (andrew@, subarctic.org, https://github.com/andrew-johnson-4) за рецензирование этого текста.

Ссылки

Справочник по С++

Библиотека линейной алгебры Эйгена

Лямбда-выражения в C++

Основы векторизации Intel