À la fin de ce chapitre, vous saurez :
autograd.PyTorch est une bibliothèque Python de machine learning open-source développée par Facebook (FAIR). Elle est conçue pour faciliter la création et l'entraînement de modèles, en particulier dans le domaine du deep learning.
Elle repose principalement sur deux éléments :
A) Les tenseurs, des structures de données similaires aux tableaux NumPy (ndarray), mais avec des fonctionnalités supplémentaires pour :
B) Le module autograd permet de calculer automatiquement les gradients nécessaires à l'entraînement des modèles, en suivant toutes les opérations effectuées sur les tenseurs.
D'autres bibliothèques Python similaires existent, comme :
Dans le cadre de ce cours, nous utiliserons PyTorch car :
Les tenseurs sont la structure de base de PyTorch. Ce sont des tableaux multidimensionnels similaires aux ndarray de NumPy, mais avec des fonctionnalités supplémentaires pour le GPU et le calcul automatique des gradients. Un tenseur est une structure de données qui généralise les matrices à un nombre quelconque de dimensions:
Les tenseurs à haute dimension sont très utilisés en deep learning (par exemple pour les images ou les vidéos). Nous allons voir comment créer et manipuler des tenseurs dans PyTorch. Pour utiliser les fonctions de PyTorch, il faut d'abord l'importer :
import torch
Vous devez copier-coller les exemples de code ci-dessous dans un notebook Jupyter pour les tester et voir les affichages.
Il existe plusieurs manières de créer un tenseur en PyTorch.
# Depuis une liste
a = torch.tensor([1, 2, 3])
print(a)
# Depuis une liste de listes (matrice)
b = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(b)
# On peut aussi spécifier le type de données
c = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float32)
print(c, c.dtype)
# Tenseur rempli de zéros
z = torch.zeros(2, 3)
print(z)
# Tenseur rempli de uns
o = torch.ones(2, 3)
print(o)
# Tenseur vide (valeurs non initialisées)
e = torch.empty(2, 3)
print(e)
# Identité (matrice diagonale)
eye = torch.eye(3)
print(eye)
PyTorch permet de générer facilement des suites de nombres avec des pas réguliers. Deux fonctions sont particulièrement utiles :
debutfin (attention, la borne supérieure est exclue !)pas indiqué# De 0 à 8 inclus, avec un pas de 2
r = torch.arange(0, 10, 2)
print("torch.arange(0, 10, 2) :", r)
# De 5 à 20 exclu, avec un pas de 3
r2 = torch.arange(5, 20, 3)
print("torch.arange(5, 20, 3) :", r2)
# ⚠️ Remarque : la borne supérieure (ici 10 ou 20) n'est jamais incluse
steps valeurs régulièrement espacéesdebut et fin# 5 valeurs entre 0 et 1 inclus
l = torch.linspace(0, 1, steps=5)
print("torch.linspace(0, 1, steps=5) :", l)
# 4 valeurs entre -1 et 1 inclus
l2 = torch.linspace(-1, 1, steps=4)
print("torch.linspace(-1, 1, steps=4) :", l2)
Résumé des différences
arange → on fixe le pas entre les valeurs, la fin est exclue.linspace → on fixe le nombre de valeurs, la fin est incluse.Exemple comparatif :
print(torch.arange(0, 1, 0.25)) # [0.00, 0.25, 0.50, 0.75]
print(torch.linspace(0, 1, 5)) # [0.00, 0.25, 0.50, 0.75, 1.00]
# Attention dans les exemples suivants, les crochets [] veulent dire que la valeur de la borne est incluse, contrairement à aux parenthèses () qui signifient que la borne est exclue.
# Uniforme entre [0, 1)
u = torch.rand(2, 2)
print("Uniforme [0,1) :\n", u)
# Distribution normale (moyenne=0, écart-type=1)
n = torch.randn(2, 2)
print("Normale standard (0,1) :\n", n)
# Distribution normale avec moyenne (mean) et écart-type (std) choisis
custom = torch.normal(mean=2.0, std=3.0, size=(2,2))
print("Normale (moyenne=10, écart-type=2) :\n", custom)
# Fixer la graine pour la reproductibilité
torch.manual_seed(42)
print("Reproductibilité :\n", torch.rand(2, 2)) # toujours le même résultat
Un tenseur peut avoir n’importe quelle dimension. La méthode .shape permet de connaître sa taille.
# Scalaire (0D)
s = torch.tensor(5)
print("Scalaire :", s, "shape =", s.shape)
# Vecteur (1D)
v = torch.tensor([1, 2, 3, 4])
print("Vecteur :", v, "shape =", v.shape)
# Matrice (2D)
m = torch.tensor([[1, 2, 3], [4, 5, 6]])
print("Matrice :\n", m, "shape =", m.shape)
# Tenseur 3D (par exemple, 2 matrices de taille 3x3)
t3 = torch.zeros(2, 3, 3)
print("Tenseur 3D shape =", t3.shape)
# Tenseur 4D (par exemple, un mini-batch de 10 images RGB de 32x32)
t4 = torch.zeros(10, 3, 32, 32)
print("Tenseur 4D shape =", t4.shape)
dtype) lors de la création :x = torch.tensor([1.2, 3.4, 5.6])
print(x.dtype) # float32 par défaut
x_int = x.to(torch.int32)
print(x_int, x_int.dtype)
x_float64 = x.double()
print(x_float64, x_float64.dtype)
x_int = x.to(torch.int32)
print(x_int.dtype)
PyTorch supporte de nombreuses opérations sur les tenseurs :
a = torch.tensor([1, 2, 3])
b = torch.tensor([4, 5, 6])
# Addition
print(a + b)
# Multiplication élément par élément
print(a * b)
# Produit matriciel
mat1 = torch.rand(2, 3)
mat2 = torch.rand(3, 4)
print(torch.mm(mat1, mat2))
Pour profiter de l’accélération GPU, il suffit de déplacer un tenseur sur le device CUDA. Pour cela, on a 3 options :
if torch.cuda.is_available():
device = torch.device("cuda")
tenseur_gpu = torch.zeros(2, 3, device=device)
print("Tenseur sur GPU :", tenseur_gpu)
else:
print("Pas de GPU disponible, utilisation du CPU.")
.to(device)if torch.cuda.is_available():
device = torch.device("cuda")
x_gpu = x.to(device)
print("Tenseur sur GPU :", x_gpu)
else:
print("Pas de GPU disponible, utilisation du CPU.")
if torch.cuda.is_available():
device = torch.device("cuda")
torch.set_default_device(device)
x_defaut_gpu = torch.zeros(2, 3) # sera créé sur le GPU par défaut
print("Tenseur par défaut sur GPU :", x_defaut_gpu)
else:
print("Pas de GPU disponible, utilisation du CPU.")
Une fois créés, les tenseurs peuvent être transformés et réarrangés. PyTorch fournit de nombreuses fonctions pour modifier leur forme, leurs dimensions ou leur ordre.
view et reshapeview : retourne un nouveau tenseur qui partage la même mémoire que l’original. Cela implique que le tenseur soit contigu. Un tenseur est dit contigu lorsque ses données sont stockées de manière consécutive en mémoire, c’est-à-dire que PyTorch peut lire tous les éléments dans l’ordre sans sauts.
Certaines opérations, comme la transposition (t()), rendent le tenseur non contigu, et dans ce cas view échoue.reshape : similaire à view, mais plus flexible car il tente d’utiliser la mémoire existante, mais crée une copie si nécessaire. reshape fonctionne dans tous les cas de figures.x = torch.arange(12) # tenseur 1D [0, 1, ..., 11]
print("x :", x)
# Transformer en matrice 3x4
x_view = x.view(3, 4)
print("view en 3x4 :\n", x_view)
# Transformer en matrice 2x6
x_reshape = x.reshape(2, 6)
print("reshape en 2x6 :\n", x_reshape)
Autre exemple pour illustrer la différence entre view et reshape :
# Création d'un tenseur 2x3
x = torch.arange(6).view(2, 3)
print("x :\n", x)
print("Contigu :", x.is_contiguous())
# Transposition → rend le tenseur non contigu
y = x.t()
print("\ny (transposé) :\n", y)
print("Contigu :", y.is_contiguous())
# view échoue sur un tenseur non contigu
try:
z = y.view(6)
except Exception as e:
print("\nErreur avec view :", e)
# reshape fonctionne toujours
z2 = y.reshape(6)
print("\nreshape fonctionne :", z2)
permutepermute réarrange les dimensions dans un nouvel ordre.# Exemple avec un tenseur 3D (batch, hauteur, largeur)
t = torch.randn(2, 3, 4) # forme (2, 3, 4)
print("Tenseur original :", t.shape)
# Inverser l'ordre (largeur, hauteur, batch)
p = t.permute(2, 1, 0)
print("Après permute :", p.shape)
unsqueeze et squeezeunsqueeze(dim) : ajoute une dimension de taille 1 à la position dim.squeeze() : supprime toutes les dimensions de taille 1.v = torch.tensor([1, 2, 3])
print("Forme initiale :", v.shape)
v_unsq = v.unsqueeze(0) # ajoute une dimension au début
print("Après unsqueeze(0) :", v_unsq.shape)
v_sq = v_unsq.squeeze() # supprime les dimensions de taille 1
print("Après squeeze() :", v_sq.shape)
torch.cat : concatène le long d’une dimension existante.torch.stack : empile en ajoutant une nouvelle dimension.a = torch.tensor([1, 2, 3])
b = torch.tensor([4, 5, 6])
cat = torch.cat((a, b), dim=0)
print("torch.cat :", cat)
stack = torch.stack((a, b), dim=0)
print("torch.stack :", stack)
print("Forme de stack :", stack.shape)
En Deep Learning, nous travaillons souvent avec des fonctions compliquées dépendant de plusieurs variables. Pour entraîner un modèle, nous avons besoin de calculer automatiquement les dérivées de ces fonctions. C'est là qu'intervient Autograd qui est le moteur de différentiation automatique de PyTorch.
Pour qu'un tenseur suive les opérations et calcule les gradients automatiquement, il faut définir requires_grad=True :
x = torch.tensor([2.0, 3.0], requires_grad=True)
print(x)
Ici, x est un tenseur avec suivi des gradients. Toutes les opérations futures sur ce tenseur seront enregistrées pour pouvoir calculer les dérivées automatiquement.
Toutes les opérations effectuées sur ce tenseur sont automatiquement enregistrées dans un graphe computationnel dynamique.
y = x ** 2 + 3 * x # y = [y1, y2]
print(y)
Dans ce cas :
x est la variable d'entrée.y est calculé à partir de x avec les opérations x**2 et 3*x.Chaque opération devient un nœud du graphe et PyTorch garde la trace des dépendances pour pouvoir calculer les gradients.
Un graphe computationnel est une structure qui représente toutes les opérations effectuées sur les tenseurs. Chaque nœud du graphe correspond à un tenseur ou à une opération mathématique, et les arêtes indiquent les dépendances entre eux.
torchvizPour visualiser le graphe dans PyTorch, on peut utiliser torchviz (qu'il faudra installer avec pip install torchviz) :
from torchviz import make_dot
z = y.sum()
make_dot(z, params={'x': x})
Cela produira une image avec des nœuds pour chaque opération et des flèches indiquant les dépendances :
Quand on visualise le graphe interne avec un outil comme torchviz :
z).PowBackward0, AddBackward0, etc.) représentent
les opérations qui seront différentiées telles que PowBackward0 est l'opération inverse associée à x**2, MulBackward0 celle associée à 3*x,AddBackward0 combine les deux résultats et représente y et enfin SumBackward0 correspond au y.sum() qui est égal à z.AccumulateGrad correspond à l’endroit où le gradient est stocké
dans la variable d’entrée (ici x.grad).Autograd utilise ce graphe pour calculer automatiquement les dérivées par rapport à x, en utilisant la méthode backward() :
z = y.sum() # z = y1 + y2
z.backward()
print(x.grad)
backward() calcule les dérivées de z par rapport à chaque élément de x.x.grad contient maintenant les gradients.Le but est de minimiser une fonction de perte en ajustant les paramètres du modèle. La rétropropagation permet de calculer efficacement les gradients nécessaires pour mettre à jour ces paramètres via des algorithmes d'optimisation comme la descente de gradient.
Le gradient d’une fonction \(f(x)\) est la pente de la courbe en un point. Le gradient indique la direction de variation la plus forte de la fonction : c’est comme une boussole qui pointe vers la direction où la fonction croît le plus vite. Pour minimiser la perte, on avance dans la direction opposée. Voici comment on calcule le gradient :
Par exemple si \(f(x) = x^2\) alors :

Pour minimiser la fonction de perte, il faut trouver \(x\) pour lequel \(\nabla f(x) = 0\).
Attention : un gradient nul peut aussi correspondre à un maximum. En apprentissage, on espère converger vers un minimum.
Le principe de la rétropropagation signifie que PyTorch parcourt le graphe en sens inverse pour faire le calcul des dérivées. Si on repart sur l'exemple de la section précédente, la rétropropagation dans PyTorch :
z.y puis x) en appliquant la règle de dérivation.x.grad.On obtient donc :
print(x.grad) # tensor([7., 9.])
On a \(y = [y_1, y_2] = [x_1² + 3x_1, x_2² + 3x_2]\) et \(z = y_1 + y_2\).
Étape 1 : dérivée de z par rapport à y
Comme \(z = y_1 + y_2\), on a \(\frac{dz}{dy_1} = 1\) et \(\frac{dz}{dy_2} = 1\).
On peut regrouper sous forme vectorielle, telle que \(\frac{dz}{dy} = [\frac{dz}{dy_1}, \frac{dz}{dy_2}] = [1, 1]\).
Étape 2 : dérivée de y par rapport à x
On a \(\frac{dy_1}{dx_1} = 2x_1 + 3\) et \(\frac{dy_2}{dx_2} = 2x_2 + 3\). On peut aussi regrouper sous forme vectorielle, telle que \(\frac{dy}{dx} = [\frac{dy_1}{dx_1}, \frac{dy_2}{dx_2}] = [2x_1 + 3, 2x_2 + 3]\).
Étape 3 : application de la règle de la chaîne
Pour obtenir les dérivées de z par rapport à x, on applique la règle de la chaîne :
\(\frac{dz}{dx} = [\frac{dz}{dx_1}, \frac{dz}{dx_2}] = \frac{dz}{dy} * \frac{dy}{dx}\) et \(\frac{dz}{dx} = [\frac{dz}{dy_1}*\frac{dy_1}{dx_1}, \frac{dz}{dy_2}*\frac{dy_2}{dx_2}] = [1 * (2x_1 + 3), 1 * (2x_2 + 3)]\)
et donc \(\frac{dz}{dx} = [2x_1 + 3, 2x_2 + 3]\).
print(x) # tensor([2., 3.], requires_grad=True)
print(x.grad) # tensor([7., 9.])
Car :
Ainsi, Autograd reproduit automatiquement ce calcul grâce au graphe computationnel et à la règle de la chaîne.
En PyTorch, il est souvent utile de séparer certaines opérations du calcul des gradients. Voici trois outils pour cela : .detach(), .clone() et torch.no_grad().
.detach()x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
z = y.detach() # z ne calcule pas de gradient
print(z)
.clone()requires_grad=True.y_clone = y.clone()
print(y_clone)
torch.no_grad()with torch.no_grad():
y_no_grad = x * 2
print(y_no_grad)
Considérons la fonction suivante : \(f(a) = a^2 + a\), avec \(a = 1.0\).
Consigne : Utiliser les deux approches suivantes pour calculer le gradient de cette fonction par rapport à \(a\) :
1) Calculez à la main la dérivée de \(f\) par rapport à \(a\). Puis évaluez ce gradient pour \(a = 1.0\).
2) Faites l'implémentation de la même fonction avec PyTorch, calculez et évaluez son gradient.
3) Comparez le résultat obtenu par PyTorch avec le calcul manuel.
Astuce :
La dérivée de \(f(a)\) par rapport à \(a\) est égale à \(2a + 1\)
Résultat attendu : Le gradient est égal à 3 dans les deux cas.