Apprentissage profond à partir de zéro en C++ moderne [2/8] : programmation tensorielle

1. Descriptif

        Texte pour débutants : ce texte nécessite une formation de niveau débutant en programmation et une compréhension de base de l'apprentissage automatique. Les tenseurs sont le principal moyen de représenter les données dans les algorithmes d'apprentissage en profondeur. Ils sont largement utilisés pour implémenter les entrées, les sorties, les paramètres et l'état interne lors de l'exécution de l'algorithme.

        Dans cette histoire, nous allons apprendre à utiliser l'API feature tensor pour développer notre algorithme C++. Plus précisément, nous aborderons :

  • qu'est-ce que le tenseur
  • Comment définir le tenseur en C++
  • Comment calculer les opérations tensorielles
  • Réduction du tenseur et convolution

        À la fin de cet article, nous implémenterons Softmax comme exemple illustratif d'application de tenseurs à des algorithmes d'apprentissage en profondeur.

2. Qu'est-ce que le tenseur ?

Les tenseurs sont des structures de données en forme de grille qui généralisent le concept de vecteurs et de matrices avec n'importe quel nombre d'axes. En machine learning, on utilise généralement le mot « dimension » au lieu d'« axe ». Le nombre de dimensions différentes d'un tenseur est également appelé rang du tenseur :

différents tenseurs de rang

En pratique, nous utilisons des tenseurs pour représenter des données dans des algorithmes et effectuer des opérations arithmétiques avec eux.

Les opérations les plus simples que nous pouvons effectuer avec des tenseurs sont des opérations dites élément par élément : étant donné deux tenseurs d'opérandes de même dimension, cette opération produit un nouveau tenseur de même dimension dont la valeur de chaque coefficient est obtenue à partir d'une évaluation binaire de l'individu éléments dans les opérandes :

multiplication des coefficients

L'exemple ci-dessus est une représentation graphique du produit des coefficients de deux tenseurs de rang 2. Cette opération fonctionne toujours pour deux tenseurs quelconques, puisqu'ils ont les mêmes dimensions.

Comme les matrices, nous pouvons utiliser des tenseurs pour effectuer d'autres opérations plus complexes telles que le produit de type matrice, la convolution, le rétrécissement, la réduction et d'innombrables opérations géométriques. Dans cette histoire, nous allons apprendre à utiliser l'API Feature Tensor pour effectuer certaines de ces opérations de tenseur, en nous concentrant sur les opérations les plus importantes pour la mise en œuvre d'algorithmes d'apprentissage en profondeur.

3. Comment déclarer et utiliser des tenseurs en C++

        Comme nous le savons tous, Eigen est une bibliothèque d'algèbre linéaire largement utilisée pour les calculs matriciels. En plus de la prise en charge bien connue des matrices, Eigen possède également un module tenseur (non pris en charge).

Bien que l'API Eigen Tensor indique qu'elle n'est pas prise en charge, elle est en fait bien prise en charge par les développeurs du framework TensorFlow de Google.

        Nous pouvons facilement définir des tenseurs en utilisant des traits :

#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;
}

La Banque

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

Créez un objet Tensor et allouez la mémoire nécessaire pour stocker les entiers. Dans cet exemple, est un tenseur de rang 3 où la première dimension a la taille 2, la deuxième dimension a la taille 3 et la dernière dimension a la taille 4. Nous pouvons l'exprimer ainsi :2x3x4my_tensormy_tensor

Nous pouvons définir des données de tenseur si nécessaire :

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

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

Ou utilisez plutôt des valeurs aléatoires. Par exemple, nous pouvons faire :

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

        Et utilisez ce noyau plus tard pour effectuer une convolution. Nous couvrirons les convolutions sous peu dans cette histoire. Tout d'abord, apprenons à utiliser TensorMaps.

4. Utilisez Eigen :: TensorMap pour créer une vue tenseur

Parfois, nous allouons des données et voulons simplement les manipuler à l'aide de tenseurs. Semblable à mais au lieu d'allouer de nouvelles données, c'est juste une vue des données passées en paramètre. Vérifiez les exemples suivants :Eigen::TensorMapEigen::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";

Dans cet exemple, il est facile de voir que (par défaut) les tenseurs de l'API Feature Tensor sont col -major . col-major et row-major font référence à la manière dont les données de la grille sont stockées dans des conteneurs linéaires (consultez cet article sur Wikipedia ):

source

Bien que nous puissions utiliser des tenseurs de taille de ligne, cela n'est pas recommandé :

Actuellement, seule la disposition colonne principale par défaut est entièrement prise en charge, il n'est donc pas recommandé d'essayer d'utiliser une disposition ligne principale pour le moment.

Eigen::TensorMapTrès utile car nous pouvons l'utiliser pour économiser de la mémoire, ce qui est crucial pour les applications exigeantes comme les algorithmes d'apprentissage en profondeur.

5. Effectuer des opérations unaires et binaires

        L'API Eigen Tensor définit des opérateurs arithmétiques surchargés communs, ce qui rend la programmation avec Tensors intuitive et simple. Par exemple, nous pouvons additionner et soustraire des tenseurs :

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

L'API de tenseur de caractéristiques a plusieurs autres fonctions élément par élément telles que , et . De plus, nous pouvons utiliser comme suit :.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";

De même, nous pouvons utiliser :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";

6. Évaluation paresseuse et mot-clé automatique

Les ingénieurs de Google qui ont développé l'API Eigen Tensor ont suivi la même stratégie qu'au sommet de la bibliothèque Eigen. L'une de ces stratégies, et probablement la plus importante, est la façon dont les expressions sont évaluées paresseusement.

La stratégie d'évaluation paresseuse consiste à retarder l'évaluation réelle d'une expression afin que plusieurs expressions chaînées puissent être combinées en une seule expression équivalente optimisée. Ainsi, au lieu d'évaluer plusieurs expressions distinctes de manière incrémentielle, le code optimisé n'évalue qu'une seule expression, dans le but d'exploiter les performances globales résultantes.

Par exemple, l'expression ne calcule pas réellement la somme de A et B si somme est un tenseur. En effet, l'expression produit un objet spécial qui sait évaluer . L'opération réelle ne sera effectuée que lorsque cet objet spécial est affecté à un tenseur réel. Autrement dit, dans l'énoncé suivant :ABA + BA + BA + B

auto C = A + B;

CPas le résultat réel, mais juste un objet calculé (vraiment un objet) qui sait calculer. Ce n'est que lorsqu'il est affecté à un objet tenseur (objet de type , , etc.) qu'il sera évalué pour fournir la valeur tenseur correcte :A + BEigen::TensorCwiseBinaryOpA + BCEigen::TensorEigen::TensorMapEigen::TensorRef

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

Bien sûr, cela n'a pas de sens pour de petites opérations comme celle-ci. Cependant, ce comportement est utile pour les longues chaînes d'opérations où le calcul peut être optimisé avant l'évaluation réelle. En résumé, en règle générale, au lieu d'écrire du code comme celui-ci :A + B

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

Nous devrions écrire un code comme celui-ci :

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

La différence est que dans le premier cas, ce sont en fait des objets, alors que dans le second code, ce ne sont que des opérations de calcul paresseuses.CDEigen::Tensor

En récupération, il est préférable d'utiliser le calcul paresseux pour évaluer de longues chaînes d'opérations, car la chaîne sera optimisée en interne, ce qui se traduira finalement par une exécution plus rapide.

7. Opérations géométriques

Les opérations géométriques produisent des tenseurs de dimensions et parfois de tailles variables. Voici des exemples de ces opérations : , , et .reshapepadshufflestridebroadcast

Il convient de noter que l'API de tenseur de fonctionnalités n'a aucune opération. Cependant, nous pouvons le simuler en utilisant :transposetransposeshuffle

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

Plus tard, lorsque nous discuterons d'exemples utilisant des tenseurs, nous verrons quelques exemples d'opérations géométriques.softmax

8. Réduire

        Une réduction est un cas particulier d'opération qui se traduit par un tenseur avec une dimensionnalité inférieure à celle du tenseur d'origine. Un cas intuitif de réduction est :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";

Dans l'exemple ci-dessus, nous avons réduit toutes les tailles une fois. Nous pouvons également effectuer des réductions selon des axes spécifiques. Par exemple:

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

        L'API de tenseur de caractéristiques dispose d'un ensemble d'opérations de réduction prédéfinies telles que , , , etc. Si l'une des opérations prédéfinies ne convient pas à une implémentation particulière, nous pouvons fournir un foncteur personnalisé comme argument.prodanyallmeanreduce(dims, reducer)reducer

Neuf, convolution du tenseur

        Dans une histoire précédente , nous avons appris à implémenter la convolution 2D en utilisant uniquement du C++ vanille et la matrice de fonctionnalités. En fait, cela est nécessaire car il n'y a pas de convolution matricielle intégrée dans Eigen. Heureusement, l'API EigenTensor dispose d'une fonction pratique pour effectuer une convolution sur les objets EigenTensor :

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

Notez que nous pouvons effectuer des convolutions 2D, 3D, 4D, etc. en contrôlant les dimensions des diapositives dans la convolution.

10. Soft maximum avec tenseur

        Lors de la programmation de modèles d'apprentissage en profondeur, nous utilisons des tenseurs au lieu de matrices. Il s'avère que les matrices peuvent représenter une ou tout au plus des grilles bidimensionnelles, alors que nous avons des images multicanaux de données de plus grande dimension ou des registres par lots à traiter. C'est là que les tenseurs entrent en jeu.

        Considérons l'exemple suivant, où nous avons deux lots de registres, chaque lot a 4 registres et chaque registre a 3 valeurs :

        Nous pouvons représenter ces données comme suit :

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

        Maintenant, appliquons à ces données :softmax

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

        Softmax est une fonction d'activation populaire. Nous avons couvert sa mise en œuvre dans une histoire précédente . Maintenant, introduisons l'implémentation :Eigen::MatrixEigen::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;
}

        Ce code affiche :

        Nous ne couvrirons pas Softmax en détail ici. Si vous avez besoin de vérifier l'algorithme Softmax, n'hésitez pas à relire l'histoire précédente sur Medium . Pour l'instant, nous nous concentrerons uniquement sur la compréhension de l'utilisation des tenseurs de caractéristiques pour coder nos modèles d'apprentissage en profondeur.

        La première chose à noter est que cette fonction ne calcule pas réellement la valeur softmax des paramètres. En fait, ne montez qu'un objet complexe capable de calculer le softmax.softmax(z)zsoftmax(z)

        La valeur réelle n'est évaluée que lorsque le résultat de est affecté à un objet de type tenseur. Par exemple, ici :softmax(z)

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

        Avant cette ligne, tout n'est qu'un graphique de calcul de softmax, en espérant être optimisé. Cela se produit uniquement parce que nous utilisons des mots-clés dans le corps de . Ainsi, l'API de tenseur de caractéristiques peut optimiser l'ensemble du calcul en utilisant moins d'opérations, ce qui améliore le traitement et l'utilisation de la mémoire.autosoftmax(z)softmax(z)

        Avant de clore cette histoire, je voudrais souligner et faire appel à :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)est une opération géométrique spéciale qui produit un autre tenseur de la même taille que le tenseur d'origine, mais avec des dimensions différentes. Reshape ne change pas l'ordre des données à l'intérieur du tenseur. Par exemple:

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

Notez que, dans cet exemple, la taille de X et Y est soit 6 bien qu'ils aient une géométrie très différente.

tensor.broadcast(bcast) répète le tenseur autant de fois que prévu dans le paramètre pour chaque dimension. Par exemple: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";

Autrement, le rang du tenseur (c'est-à-dire la dimensionnalité) ne sera pas modifié, mais seule la taille de la dimensionnalité sera augmentée.reshapebroadcast

11. Limites

La documentation de l'API Eigen Tensor cite certaines limitations dont nous pouvons être conscients :

  • La prise en charge du GPU est testée et optimisée pour les types à virgule flottante. Même si nous pouvions le dire, l'utilisation de tenseurs à virgule non flottante est déconseillée lors de l'utilisation du GPU.Eigen::Tensor<int,...> tensor;
  • La mise en page par défaut (col-major) est la seule réellement prise en charge. Au moins pour l'instant, nous ne devrions pas utiliser les lignes majeures.
  • Le nombre maximal de dimensions est de 250. Cette taille n'est réalisable qu'avec un compilateur compatible C++11.

12. Conclusion et prochaines étapes

        Les tenseurs sont la structure de données fondamentale de la programmation d'apprentissage automatique, nous permettant de représenter et de traiter des données multidimensionnelles aussi directement que des matrices bidimensionnelles régulières.

Dans cette histoire, nous avons présenté l'API de tenseur de fonctionnalités et appris à utiliser les tenseurs avec une relative facilité. Nous avons également appris que l'API Feature Tensor dispose d'un mécanisme d'évaluation paresseux qui optimise l'exécution en termes de mémoire et de temps de traitement.

Pour nous assurer que nous comprenons vraiment l'utilisation de l'API Eigen Tensor, nous passons en revue un exemple d'encodage Softmax à l'aide de tenseurs.

Dans les prochaines histoires, nous continuerons à développer des algorithmes d'apprentissage en profondeur hautes performances à partir de zéro en utilisant C++ et Eigen, en particulier en utilisant l'API Eigen Tensor.

Treize, code github

Vous pouvez  trouver le code utilisé dans cette histoire dans ce référentiel sur GitHub .

14. Citation

[1]  API de tenseur de fonctionnalités

[2]  Module tenseur de fonctionnalités

[3] Propre référentiel Gitlab,  libown / propre · GitLab

[4] Charu C. Aggarwal,  Réseaux de neurones et apprentissage en profondeur: A Textbook  (2018), Springer

[5] Jason Brownlee, Une introduction en douceur aux tenseurs pour l'apprentissage automatique avec NumPy

À propos de cette série

Dans cette série , nous apprendrons à coder des algorithmes d'apprentissage en profondeur indispensables tels que les convolutions, la rétropropagation, les fonctions d'activation, les optimiseurs, les réseaux de neurones profonds, etc., en utilisant uniquement du C++ simple et moderne.

L'histoire est la suivante : utiliser l'API de tenseur de fonctionnalités

Découvrez d'autres histoires:

0 — Principes de base de la programmation d'apprentissage en profondeur C++ moderne

1 — Coder une convolution 2D en C++ pur

2 — Fonction de coût utilisant Lambda

3 - Mise en œuvre de la descente de gradient

4 — fonction d'activation

... et bien d'autres à venir.

Je suppose que tu aimes

Origine blog.csdn.net/gongdiwudu/article/details/132160022
conseillé
Classement