Нека се забавляваме, като научим как да използваме API на Eigen Tensor.

Тензорите са основният начин за представяне на данни в алгоритми за дълбоко обучение. Те се използват широко за реализиране на входове, изходи, параметри и вътрешни състояния по време на изпълнение на алгоритъма.

В тази история ще научим как да използваме API на Eigen Tensor, за да разработим нашите 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 ints. В този пример 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";

В този пример е лесно да се види, че (по подразбиране) тензорите в API на Eigen Tensor са col-major. col-major и row-major се отнасят до начина, по който данните от мрежата се съхраняват в линейни контейнери (проверете тази статия в Wikipedia):

Въпреки че можем да използваме главни тензори, не се препоръчва:

Понастоящем се поддържа напълно само основното оформление на колоните по подразбиране и следователно не се препоръчва да се опитвате да използвате основното оформление на реда в момента.

Eigen::TensorMap е много полезно, защото можем да го използваме за пестене на памет, което е критично за приложения с високи изисквания, като например алгоритми за дълбоко обучение.

Извършване на унарни и бинарни операции

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

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";

API на Eigen Tensor има няколко други елементни функции като .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, които работиха върху Eigen Tensor API, следваха същите стратегии, намерени в горната част на Eigen Library. Една от тези стратегии и вероятно най-важната е начинът, по който изразите се оценяват мързеливо.

Стратегията за мързелива оценка се състои от забавяне на действителната оценка на изрази, за да се комбинират множество верижни изрази в оптимизиран еквивалентен. По този начин, вместо да оценява множество индивидуални изрази стъпка по стъпка, оптимизираният код оценява само един израз, като цели да увеличи крайната обща производителност.

Например, ако 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 .

Трябва да се отбележи, че API на Eigen Tensor няма операция 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";

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

Тензорни навивки

В една от предишните истории научихме как да реализираме 2D навивки, използвайки само обикновен C++ и Eigen матрици. Наистина беше необходимо, защото няма вградена конволюция за матрици в Eigen. За щастие API на Eigen Tensor има удобна функция за извършване на навивки върху обекти на 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). По този начин API на Eigen Tensor може да оптимизира цялото изчисление на 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 цитират някои ограничения, за които можем да знаем:

  • Поддръжката на GPU беше тествана и оптимизирана за плаващ тип. Дори ако можем да декларираме Eigen::Tensor<int,...> tensor;, използването на не-float тензори не се препоръчва, когато се използва GPU.
  • Оформлението по подразбиране (col-major) е единственото действително поддържано. Поне засега не трябва да използваме row-major.
  • Максималният брой измерения е 250. Този размер се постига само при използване на C++11-съвместим компилатор.

Заключение и следващи стъпки

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

В тази история представихме API на Eigen Tensor и научихме как да използваме тензори с относителна лекота. Научихме също, че API на Eigen Tensor има механизъм за мързелива оценка, който води до оптимизирано изпълнение по отношение на паметта и времето за обработка.

За да сме сигурни, че наистина разбираме използването на Eigen Tensor API, разгледахме пример за кодиране на Softmax с помощта на тензори.

В следващите истории ще продължим нашето пътуване за разработване на високоефективни алгоритми за дълбоко обучение от нулата, използвайки C++ и Eigen, по-специално, използвайки API на Eigen Tensor.

Код

Можете да намерите кода, използван в тази история в това хранилище на GitHub.

Препратки

[1] Eigen Tensor API

[2] Модул на собствения тензор

[3] Eigen Gitlab хранилище, https://gitlab.com/libeigen/eigen

[4] Charu C. Aggarwal, Невронни мрежи и дълбоко обучение: учебник (2018), Springer

[5] Джейсън Браунли, Нежно въведение в тензорите за машинно обучение с NumPy

Относно този сериал

В тази поредица ще научим как да кодираме задължителните алгоритми за задълбочено обучение като навивки, обратно разпространение, функции за активиране, оптимизатори, дълбоки невронни мрежи и т.н., като използваме само обикновен и модерен C++.

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

Вижте други истории:

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

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

2 — Функции на разходите, използващи ламбда

3 — Прилагане на градиентно спускане

4 — Функции за активиране

… има още.

Повече съдържание в PlainEnglish.io.

Регистрирайте се за нашия безплатен седмичен бюлетин. Следвайте ни в Twitter, LinkedIn, YouTube и Discord .