Давайте повеселимся, изучив, как использовать Eigen Tensor API.

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

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

  • Что такое тензоры
  • Как определить тензоры в C++
  • Как вычислять тензорные операции
  • Тензорные редукции и свертки

В конце этой истории мы реализуем Softmax как наглядный пример применения тензоров для алгоритмов глубокого обучения.

Текст для начинающих: этот текст требует начального уровня программирования и базового понимания машинного обучения.

Что такое тензор?

Тензоры представляют собой сеткообразные структуры данных, которые обобщают концепции векторов и матриц для произвольного числа осей. В машинном обучении мы обычно используем слово «измерение» вместо «ось». Количество различных измерений тензора также называется тензорным рангом:

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

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

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

Как и с матрицами, мы можем выполнять другие более сложные операции с тензорами, такие как матричное произведение, свертки, сокращения, редукции и множество геометрических операций. В этой истории мы узнаем, как использовать Eigen Tensor API для выполнения некоторых из этих тензорных операций, сосредоточив внимание на наиболее важных для реализации алгоритмов глубокого обучения.

Как объявлять и использовать тензоры в C++

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

Хотя API Eigen Tensor обозначен как неподдерживаемый, на самом деле он хорошо поддерживается разработчиками платформы Google TensorFlow.

Мы можем легко определить тензор, используя Eigen:

#include <iostream>

#include <unsupported/Eigen/CXX11/Tensor>

int main(int, char **)
{

    Eigen::Tensor<int, 3> my_tensor(2, 3, 4);
    my_tensor.setConstant(42);

    std::cout << "my_tensor:\n\n" 
              << my_tensor << "\n\n";

    std::cout << "tensor size is " << my_tensor.size() << "\n\n"; 

    return 0;
}

Линия

Eigen::Tensor<int, 3> my_tensor(2, 3, 4);

создает тензорный объект и выделяет необходимую память для хранения 2x3x4 целых чисел. В этом примере my_tensor является 3-ранговым тензором, где размер первого измерения равен 2, размер второго измерения равен 3, а размер последнего измерения равен 4. Мы можем представить my_tensor следующим образом:

Мы можем установить данные тензора, если захотим:

my_tensor.setValues({{{1, 2, 3, 4}, {5, 6, 7, 8}}});

std::cout << "my_tensor:\n\n" << my_tensor << "\n\n";

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

Eigen::Tensor<float, 2> kernel(3, 3);
kernel.setRandom();
std::cout << "kernel:\n\n" << kernel << "\n\n";

и использовать это ядро ​​позже для выполнения сверток. Мы скоро рассмотрим извилины в этой истории. Во-первых, давайте научимся использовать TensorMaps.

Создание тензорных представлений с помощью Eigen::TensorMap

Иногда у нас есть выделенные данные, и мы хотим манипулировать ими только с помощью тензора. Eigen::TensorMap похож на Eigen::Tensor, но вместо выделения новых данных это только просмотр данных, переданных в качестве параметра. Проверьте пример ниже:

//an vector with size 12
std::vector<float> storage(4*3);

// filling vector from 1 to 12
std::iota(storage.begin(), storage.end(), 1.);

for (float v: storage) std::cout << v << ','; 
std::cout << "\n\n";

// setting a tensor view with 4 rows and 3 columns
Eigen::TensorMap<Eigen::Tensor<float, 2>> my_tensor_view(storage.data(), 4, 3);

std::cout << "my_tensor_view before update:\n\n" << my_tensor_view << "\n\n";

// updating the vector
storage[4] = -1.;

std::cout << "my_tensor_view after update:\n\n" << my_tensor_view << "\n\n";

// updating the tensor
my_tensor_view(2, 1) = -8;

std::cout << "vector after two updates:\n\n";
for (float v: storage) std::cout << v << ','; 
std::cout << "\n\n";

В этом примере легко увидеть, что (по умолчанию) тензоры в Eigen Tensor API имеют значение col-major. col-major и row-major относятся к тому, как данные сетки хранятся в линейных контейнерах (см. эту статью в Википедии):

Хотя мы можем использовать тензоры по строкам, это не рекомендуется:

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

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

Выполнение унарных и бинарных операций

Eigen Tensor API определяет общие арифметические операторы перегрузки, что делает программирование тензоров очень интуитивно понятным и простым. Например, мы можем складывать и вычитать тензоры:

Eigen::Tensor<float, 2> A(2, 3), B(2, 3);
A.setRandom();
B.setRandom();

Eigen::Tensor<float, 2> C = 2.f*A + B.exp();

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

Eigen Tensor API имеет несколько других поэлементных функций, таких как .exp(), таких как sqrt(), log() и abs(). Кроме того, мы можем использовать unaryExpr(fun) следующим образом:

auto cosine = [](float v) {return cos(v);};

Eigen::Tensor<float, 2> D = A.unaryExpr(cosine);

std::cout << "D is\n\n"<< D << "\n\n";

Точно так же мы можем использовать binaryExpr :

auto fun = [](float a, float b) {return 2.*a + b;};

Eigen::Tensor<float, 2> E = A.binaryExpr(B, fun);

std::cout << "E is\n\n"<< E << "\n\n";

Ленивая оценка и ключевое слово auto

Инженеры Google, работавшие над API Eigen Tensor, следовали тем же стратегиям, что и в верхней части библиотеки Eigen. Одна из этих стратегий и, возможно, самая важная — способ ленивой оценки выражений.

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

Например, если A и B являются тензорами, выражение A + B на самом деле не вычисляет сумму A и B. На самом деле выражениеA + B приводит к созданию специального объекта, который знает, как вычислить A + B . Фактическая операция будет выполнена только тогда, когда этот специальный объект будет назначен фактическому тензору. Другими словами, в следующем утверждении:

auto C = A + B;

C не является фактическим результатом A + B, а просто объектом вычисления (на самом деле объектом Eigen::TensorCwiseBinaryOp), который знает, как вычислить A + B. Только когда C присваивается тензорному объекту (объекту типа Eigen::Tensor, Eigen::TensorMap, Eigen::TensorRef и т. д.), он будет оцениваться для предоставления правильных значений тензора:

Eigen::Tensor<...> T = C;
std::cout << "T is " << T << "\n\n";

Конечно, это не имеет смысла для небольших операций, таких как A + B. Однако такое поведение действительно полезно для длинных цепочек операций, где вычисления могут быть оптимизированы до фактической оценки. В резюме, как общее правило, вместо того, чтобы писать такой код:

Eigen::Tensor<...> A = ...;
Eigen::Tensor<...> B = ...;
Eigen::Tensor<...> C = B * 0.5f;
Eigen::Tensor<...> D = A + C;
Eigen::Tensor<...> E = D.sqrt();

мы должны написать такой код:

Eigen::Tensor<...> A = ...;
Eigen::Tensor<...> B = ...;
auto C = B * 0.5f;
auto D = A + C;
Eigen::Tensor<...> E = D.sqrt();

Разница в том, что в первом случае C и D на самом деле являются объектами Eigen::Tensor, тогда как в более позднем коде они являются только операциями ленивых вычислений.

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

Геометрические операции

Геометрические операции приводят к тензорам с разными размерностями, а иногда и размерами. Примеры этих операций: reshape, pad, shuffle, stride и broadcast.

Примечательно, что в Eigen Tensor API нет операции transpose. Однако мы можем эмулировать transpose, используя shuffle:

auto transpose(const Eigen::Tensor<float, 2> &tensor) {
    Eigen::array<int, 2> dims({1, 0});
    return tensor.shuffle(dims);
}

Eigen::Tensor<float, 2> a_tensor(3, 4);
a_tensor.setRandom();

std::cout << "a_tensor is\n\n"<< a_tensor << "\n\n";
std::cout << "a_tensor transpose is\n\n"<< transpose(a_tensor) << "\n\n";

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

Скидки

Редукции — это частный случай операций, результатом которых является тензор с меньшей размерностью, чем исходный. Интуитивно понятные случаи сокращений: sum() и maximum() :

Eigen::Tensor<float, 3> X(5, 2, 3);
X.setRandom();

std::cout << "X is\n\n"<< X << "\n\n";

std::cout << "X.sum(): " << X.sum() << "\n\n";
std::cout << "X.maximum(): " << X.maximum() << "\n\n";

В приведенном выше примере мы уменьшили все размеры один раз. Мы также можем выполнять редукции по определенным осям. Например:

Eigen::array<int, 2> dims({1, 2});

std::cout << "X.sum(dims): " << X.sum(dims) << "\n\n";
std::cout << "X.maximum(dims): " << X.maximum(dims) << "\n\n";

Eigen Tensor API имеет набор предварительно созданных операций сокращения, таких как prod , any , all , mean и другие. Если какая-либо из предварительно созданных операций не подходит для конкретной реализации, мы можем использовать reduce(dims, reducer), предоставив в качестве параметра пользовательскийreducer функтор.

Тензорные свертки

В одной из предыдущих историй мы научились реализовывать двумерные свертки, используя только простой C++ и собственные матрицы. Действительно, это было необходимо, потому что в Eigen нет встроенной свертки для матриц. К счастью, в Eigen Tensor API есть удобная функция для выполнения сверток с объектами Eigen Tensor:

Eigen::Tensor<float, 4> input(1, 6, 6, 3);
input.setRandom();

Eigen::Tensor<float, 2> kernel(3, 3);
kernel.setRandom();

Eigen::Tensor<float, 4> output(1, 4, 4, 3);

Eigen::array<int, 2> dims({1, 2});
output = input.convolve(kernel, dims);

std::cout << "input:\n\n" << input << "\n\n";
std::cout << "kernel:\n\n" << kernel << "\n\n";
std::cout << "output:\n\n" << output << "\n\n";

Обратите внимание, что мы можем выполнять свертки 2D, 3D, 4D и т. д., контролируя размеры слайда в свертке.

Softmax с тензорами

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

Давайте рассмотрим следующий пример, где у нас есть два набора регистров, каждый пакет имеет 4 регистра и каждый регистр имеет 3 значения:

Мы можем представить эти данные следующим образом:

Eigen::Tensor<float, 3> input(2, 4, 3);
input.setValues({
    {{0.1, 1., -2.},{10., 2., 5.},{5., -5., 0.},{2., 3., 2.}},
    {{100., 1000., -500.},{3., 3., 3.},{-1, 1., -1.},{-11., -0.2, -.1}}
});

std::cout << "input:\n\n" << input << "\n\n";

Теперь давайте применим softmax к этим данным:

Eigen::Tensor<float, 3> output = softmax(input);
std::cout << "output:\n\n" << output << "\n\n";

Softmax — популярная функция активации. Мы освещали его реализацию с помощью Eigen::Matrixв предыдущей истории. Теперь давайте представим реализацию, используя вместо этого Eigen::Tensor:

#include <unsupported/Eigen/CXX11/Tensor>

auto softmax(const Eigen::Tensor<float, 3> &z)
{

    auto dimensions = z.dimensions();

    int batches = dimensions.at(0);
    int instances_per_batch = dimensions.at(1);
    int instance_length = dimensions.at(2);

    Eigen::array<int, 1> depth_dim({2});
    auto z_max = z.maximum(depth_dim);

    Eigen::array<int, 3> reshape_dim({batches, instances_per_batch, 1});
    auto max_reshaped = z_max.reshape(reshape_dim);

    Eigen::array<int, 3> bcast({1, 1, instance_length});
    auto max_values = max_reshaped.broadcast(bcast);

    auto diff = z - max_values;

    auto expo = diff.exp();
    auto expo_sums = expo.sum(depth_dim);
    auto sums_reshaped = expo_sums.reshape(reshape_dim);
    auto sums = sums_reshaped.broadcast(bcast);
    auto result = expo / sums;

    return result;
}

Этот код выводит:

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

Первое, что нужно отметить, это то, что функция softmax(z) на самом деле не вычисляет значение softmax для параметра z. Фактически, softmax(z) монтирует только сложный объект, который может вычислять softmax.

Фактическое значение будет оцениваться только тогда, когда результат softmax(z) будет присвоен тензороподобному объекту. Например, здесь:

Eigen::Tensor<float, 3> output = softmax(input);

До этой строчки все только график вычислений softmax, надеюсь, оптимизированный. Это произошло только потому, что мы использовали ключевое слово auto в теле softmax(z). Таким образом, Eigen Tensor API может оптимизировать все вычисления softmax(z), используя меньше операций, что улучшает как обработку, так и использование памяти.

Прежде чем закончить этот рассказ, я хотел бы указать на вызовы tensor.reshape(dims) и tensor.broadcast(bcast):

Eigen::array<int, 3> reshape_dim({batches, instances_per_batch, 1});
auto max_reshaped = z_max.reshape(reshape_dim);

Eigen::array<int, 3> bcast({1, 1, instance_length});
auto max_values = max_reshaped.broadcast(bcast);

reshape(dims) — это специальная геометрическая операция, которая генерирует другой тензор того же размера, что и исходный тензор, но с другими размерностями. Reshape не меняет порядок данных внутри тензора. Например:

Eigen::Tensor<float, 2> X(2, 3);
X.setValues({{1,2,3},{4,5,6}});

std::cout << "X is\n\n"<< X << "\n\n";

std::cout << "Size of X is "<< X.size() << "\n\n";

Eigen::array<int, 3> new_dims({3,1,2});
Eigen::Tensor<float, 3> Y = X.reshape(new_dims);

std::cout << "Y is\n\n"<< Y << "\n\n";

std::cout << "Size of Y is "<< Y.size() << "\n\n";

Обратите внимание, что в этом примере размер X и Y равен 6, хотя они имеют очень разную геометрию.

tensor.broadcast(bcast) повторяет тензор столько раз, сколько указано в параметре bcast для каждого измерения. Например:

Eigen::Tensor<float, 2> Z(1,3);
Z.setValues({{1,2,3}});
Eigen::array<int, 2> bcast({4, 2});
Eigen::Tensor<float, 2> W = Z.broadcast(bcast);

std::cout << "Z is\n\n"<< Z << "\n\n";
std::cout << "W is\n\n"<< W << "\n\n";

В отличие от reshape, broadcast не изменяет ранг тензора (т. е. количество измерений), а только увеличивает размер измерений.

Ограничения

В документах Eigen Tensor API указаны некоторые ограничения, о которых мы можем знать:

  • Поддержка графического процессора была протестирована и оптимизирована для плавающего типа. Даже если мы можем объявить Eigen::Tensor<int,...> tensor; , использование неплавающих тензоров не рекомендуется при использовании графического процессора.
  • Макет по умолчанию (col-major) — единственный фактически поддерживаемый. Мы не должны использовать row-major, по крайней мере, сейчас.
  • Максимальное количество измерений — 250. Этот размер достигается только при использовании компилятора, совместимого с C++11.

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

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

В этой статье мы познакомились с Eigen Tensor API и относительно легко научились использовать тензоры. Мы также узнали, что Eigen Tensor API имеет механизм отложенной оценки, который приводит к оптимизированному выполнению с точки зрения памяти и времени обработки.

Чтобы убедиться, что мы действительно поняли использование Eigen Tensor API, мы рассмотрели пример кодирования Softmax с использованием тензоров.

В следующих историях мы продолжим наш путь разработки высокопроизводительных алгоритмов глубокого обучения с нуля с использованием C++ и Eigen, в частности, с использованием Eigen Tensor API.

Код

Вы можете найти код, использованный в этой истории, в этом репозитории на GitHub.

Рекомендации

[1] API собственных тензоров

[2] Собственный тензорный модуль

[3] Репозиторий Eigen Gitlab, https://gitlab.com/libeigen/eigen

[4] Чару К. Аггарвал, Нейронные сети и глубокое обучение: учебник (2018), Springer

[5] Джейсон Браунли, Нежное введение в тензоры для машинного обучения с помощью NumPy

Об этой серии

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

Эта история: Использование Eigen Tensor API

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

0 — Основы программирования глубокого обучения на современном C++

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

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

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

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

… еще не все.

Дополнительные материалы на PlainEnglish.io.

Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord .