Давайте повеселимся, реализовав модели глубокого обучения на C++.
Излишне говорить, насколько актуальны системы машинного обучения для исследований и промышленности. Благодаря их расширяемости и гибкости в настоящее время редко можно найти проект, в котором не используются Google TensorFlow или Meta PyTorch.
Тем не менее, может показаться нелогичным тратить время на кодирование алгоритмов машинного обучения с нуля, то есть без какой-либо базовой структуры. Однако это не так. Самостоятельное кодирование алгоритмов обеспечивает четкое и четкое понимание того, как работают алгоритмы и что на самом деле делают модели.
В этой серии мы научимся кодировать необходимые алгоритмы глубокого обучения, такие как свертки, обратное распространение, функции активации, оптимизаторы, глубокие нейронные сети и т. д., используя только простой и современный C++.
Мы начнем наше путешествие в этой истории с изучения некоторых современных функций языка C++ и соответствующих деталей программирования для кодирования моделей глубокого обучения и машинного обучения.
Проверьте другие истории:
1 — Кодирование 2D сверток на C++
2 — Функции стоимости с использованием лямбда-выражений
3 — Реализация градиентного спуска
… еще не все.
Современные заголовки 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) за рецензирование этого текста.