Tenseur

Dans ce chapitre, nous nous plaçons dans la configuration où tous les tenseurs sont stockés en mémoire RAM. Des informations spécifiques au GPU sont disponibles en fin de page.

Création

torch.tensor

La fonction torch.tensor(..) permet de créer un tenseur à partir d’une liste Python ou d’un tableau NumPy. Les valeurs contenues dans le tenseur sont copiées depuis les données fournies. Le tenseur et l’objet original sont indépendants : modifier les valeurs de l’un n’impacte pas l’autre.

Il faut faire attention au type, des règles implicites de conversion s’appliquent :

Contexte

Type des données du tenseur

t = torch.tensor(Liste Python d’entiers)

int64 pour les entiers

t = torch.tensor(Liste Python de flottants)

float32 pour les flottants

t = torch.tensor(Tableau NumPy)

dtype du tableau NumPy

Pour optimiser l’occupation mémoire, on peut utiliser la fonction torch.from_numpy(a) qui lie les données du nouveau tenseur aux données d’origine contenues dans le tableau NumPy. Cette approche évite ainsi une recopie des données mais elle lie les deux objets : modifier l’un modifie l’autre.

Spécifier le type

Lorsque l’on écrit a = np.array([1.0, 2.0]), NumPy crée par défaut un tableau de flottants en float64. Par conséquent, l’appel à torch.tensor(a) produira un tenseur de type torch.float64 ce qui n’est pas optimal. En effet, le type float64 est polluant. Par exemple, il suffit d’un seul tenseur en float64 pour produire par combinaison avec les autres tenseurs des résultats en float64.

Il est donc préférable de spécifier explicitement les types des données des tenseurs afin d’éviter de mauvaises surprises :

t = torch.tensor([1.0, 2.0], dtype = torch.float32)

En cas de doute, il est possible de vérifier le type sous-jacent du tenseur :

print(t.dtype)

Fonctions

Voici différentes fonctions permettant d’initialiser un tenseur en PyTorch. Pour chaque fonction, la dimension du tenseur est donnée à partir d’un tuple. Vous remarquerez la forte similarité avec NumPy.

Code

dtype

Description

Remarques

torch.zeros((2, 2))

float32

Initialisation avec des 0

tensor( [ [0., 0.], [0., 0.] ] )

torch.ones((2, 2))

float32

Initialisation avec des 1

torch.full((2, 2), 7)

float32

Initialisation avec une valeur constante

torch.empty((2, 2))

float32

Non initialisé

Valeurs indéfinies

torch.rand((2, 2))

float32

Aléatoire - loi uniforme continue [0, 1[

torch.randn((2, 2))

float32

Aléatoire - loi normale N(0,1)

torch.randint(0, 10, (2, 2))

int64

Entiers aléatoires - loi uniforme discrète

Borne supérieure exclue

Il existe aussi des fonctions pour créer des séquences :

torch.arange(0, 10, 2)

int64

Séquence 0, 2, 4, …

Tenseur 1D

torch.linspace(0, 1, steps=5)

float32

Séquence régulière, espacement constant

Tenseur 1D, bornes incluses

Tenseur scalaire

Un tenseur scalaire désigne un tenseur de dimension 0. Voici différentes syntaxes pour le créer :

torch.tensor(3.14)

Syntaxe valide

torch.scalar_tensor(3.14)

Syntaxe plus explicite

torch.scalar_tensor(x)

Accepte les variables

Un tenseur scalaire (0-D) représente un nombre (un scalaire). Il peut être utilisé pour multiplier (ou additionner) un tenseur par une valeur.

Autograd

Terminologie

Dans le cadre des réseaux de neurones, nous manipulons deux types de tenseurs :

  • Les tenseurs dont le gradient n’est pas requis, correspondant par exemple aux images en entrée

  • Les tenseurs créés avec requires_grad=True, comme les poids du réseau et les tenseurs intermédiaires impliqués dans le calcul de la Loss

Considérons un tenseur de poids et un tenseur image. D’un point de vue purement technique, ces deux tenseurs stockent des valeurs numériques. Cette similarité peut prêter à confusion car leurs rôles dans le réseau sont eux très différents :

  • Les images restent inchangées tout au long de l’apprentissage

  • À l’inverse, les valeurs des poids évoluent par application de la méthode du gradient

Pour indiquer à PyTorch qu’un tenseur contient des valeurs dont le gradient doit être connu, il est nécessaire de spécifier le paramètre requires_grad=True lors de sa création.

Graphe de calcul

La librairie Autograd interne à PyTorch est capable de construire un graphe de calcul utilisé pour calculer les gradients des tenseurs dont le paramètre requires_grad est actif.

Prenons un exemple :

\[Loss = I_0 \cdot W_0 + 8 \cdot W_1 + I_1\]

Voici une manière de coder cette expression étape par étape :

I = torch.tensor([5,7])
W = torch.tensor([3.,11.],requires_grad=True)

a = I[0] * W[0]
b = 8 * W[1]
c = a + b
Loss = c + I[1]

Grâce aux informations suivantes :

print(Loss.grad_fn)   # AddBackward0
print(c.grad_fn)      # AddBackward0
print(b.grad_fn)      # MulBackward0
print(a.grad_fn)      # MulBackward0

On peut déduire le graphe de calcul généré par Autograd :

I[0] ────────┐
             ├─ (*) ─> a = I[0]·W[0] ──┐
W[0] ────────┘                         │
                                       ├─ (+) ─> c = a + b ──┐
W[1] ────────┐                         │                     │
             ├─ (*) ─> b = 8·W[1] ─────┘                     ├─ (+) ─> Loss
8  ──────────┘                                               │
                                                             │
I[1] ────────────────────────────────────────────────────────┘

Ainsi, la librairie Autograd conserve pour chaque noeud les informations nécessaires à lé rétropopagation.

Expression complexe

La librairie Autograd est suffisamment puissante pour générer automatiquement un graphe à partir d’une expression complexe. Pour exhiber la structure du graphe sous-jacent, nous mettons en place une fonction récursive traverse() :

import torch

I = torch.tensor([5,7])
W = torch.tensor([3.,11.],requires_grad=True)

Loss = I[0]*W[0] + W[1]*8 + I[1]

def traverse(fn, depth=0):
    if fn is None : return
    print("  " * depth + type(fn).__name__)

    for parent, _ in fn.next_functions:
        traverse(parent, depth + 1)

traverse(Loss.grad_fn)

Ainsi, nous pouvons examiner le graphe généré automatiquement par Autograd :

AddBackward0                    # Loss = c + 7
    AddBackward0                # I[0]·W[0] + 8·W[1]
        MulBackward0            # I[0]·W[0]
            SelectBackward0     # W[0]
                AccumulateGrad  # gradient à accumuler dans W.grad
        MulBackward0            # 8 * W[1]
            SelectBackward0     # W[1]
                AccumulateGrad  # gradient à accumuler dans W.grad

Gradient

L’appel de Loss.backward() déclenche le mécanisme de rétropropagation. Après cela, il est possible de connaître le gradient de W en lisant l’attribut W.grad :

Loss.backward()
print(W.grad)

>> tensor([5., 8.])

Pour rappel, la fonction Loss s’exprime ainsi :

\[Loss(W) = 5 \cdot W_0 + 8 \cdot W_1 + 7\]

Ce qui après application des formules de dérivation donne une réponse identique à la rétropopagation :

\[\frac{\partial Loss}{\partial W_0} = 5 \qquad \frac{\partial Loss}{\partial W_1} = 8\]

Récupérer des résultats

Conversion vers Python

Si votre tenseur ne contient qu’une valeur, il est possible d’utiliser la fonction item() pour l’extraire directement vers Python.

x = torch.tensor(3.14)
x.item()                  # 3.14 (float Python)

Conversion vers Numpy

Pour afficher des résultats, vous aurez peut-être besoin de convertir un tenseur en tableau NumPy. Voici la syntaxe à choisir suivant le contexte :

Action

Commentaire

t.detach().numpy()

Uniquement si accès en lecture seule

t.detach().numpy().copy()

Isolation totale

PyTorch demande d’utiliser la fonction detach() pour retourner un tenseur-vue déconnecté d’Autograd. La fonction numpy() retourne un tableau NumPy correspondant le plus souvent à une vue. Ceci signifie que le tableau Numpy peut rester lié au tenseur d’origine sauf si vous appliquez une copie.

GPU

Transfert vers le GPU

Les syntaxes suivantes permettent de transférer un tenseur vers la mémoire GPU :

tgpu = t.to(« cuda »)

tgpu = t.cuda()

Pour éviter d’écrire deux versions du code, une pour CPU et une pour GPU, on utilise cette approche :

import torch

target_device  = torch.device("cuda" if torch.cuda.is_available() else "cpu")
t = t.to(target_device)

Ainsi, on transfère le tenseur t vers la mémoire GPU s’il est présent.

Création dans le GPU

S’il n’y a pas de données à transférer, il est possible de créer directement un tenseur sur le GPU. Voici un exemple :

torch.zeros( (2,2), dtype=torch.float32, device=target_device)

Cette facilité vaut pour les fonctions : zeros, ones, empty, rand, randn, full, arange, linspace…

Récupération depuis le GPU

Pour récupérer des résultats présents sur le GPU, il est nécessaire :

  • de détacher le tenseur du graphe de calcul en utilisant la fonction detach()

  • de transférer ses données vers le CPU en appelant la méthode cpu()

On utilise donc généralement les syntaxes suivantes :

Action

Commentaire

a = t.detach().cpu().numpy()

lecture seule

a = t.detach().cpu().numpy().copy()

isolation totale