Aller au contenu principal

Module 1 — Tenseurs PyTorch et interopérabilité avec NumPy

Un tenseur PyTorch ressemble à un tableau NumPy, sait tourner sur GPU et sait porter un historique de calculs. Ces trois propriétés changent tout, et les confondre est la source la plus commune d'erreurs silencieuses en début de projet. Ce module ancre le vocabulaire du reste du cours et introduit le jeu d'images Fashion-MNIST qui sert de fil rouge à tous les modules suivants.

Créer un tenseur, quatre voies canoniques

PyTorch propose plusieurs constructeurs, et le choix n'est pas interchangeable.

import torch
import numpy as np

# 1. À partir d'une liste ou d'un tableau NumPy
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])

# 2. Rempli d'une valeur, à la forme voulue
b = torch.zeros(3, 4)
c = torch.ones(2, 2, dtype=torch.float32)

# 3. Tiré au hasard, utile pour tester une architecture
d = torch.randn(8, 3, 28, 28) # 8 images factices 28x28, 3 canaux

# 4. À partir de NumPy, avec mémoire partagée
x = np.arange(6, dtype=np.float32).reshape(2, 3)
e = torch.from_numpy(x)

torch.tensor copie toujours les données et déduit le type ; c'est le constructeur explicite qu'on préfère pour convertir depuis Python. À l'inverse, torch.from_numpy partage la mémoire avec le tableau NumPy : modifier l'un modifie l'autre, sans avertissement. Ce comportement est très pratique pour éviter une copie coûteuse, il devient un piège dès qu'on croit avoir découplé deux structures.

from_numpy n'est pas une copie

Après t = torch.from_numpy(arr), une écriture arr[0] = 42 change aussi t[0], et réciproquement t[1] = 0 change arr[1]. Si vous voulez un tenseur indépendant, écrivez t = torch.from_numpy(arr).clone() — le .clone() matérialise une copie. Le même piège existe dans l'autre sens avec .numpy().

Trois attributs à toujours vérifier : forme, type, dispositif

Un tenseur est décrit par un triplet inséparable.

AttributCe qu'il porteErreur typique
shapedimensions, dans l'ordre (N, C, H, W) par défautoublier la dimension de lot en tête
dtypefloat32 par défaut, float16 ou bfloat16 en précision mixtemélanger long et float dans une opération
devicecpu ou cuda:0additionner un tenseur CPU et un tenseur GPU

Pour Fashion-MNIST, un lot de 64 images en niveaux de gris de taille 28 par 28 a la forme (64, 1, 28, 28). La dimension de tête, appelée dimension de lot, est presque toujours la première ; les couches de torch.nn s'attendent à la voir.

lot = torch.randn(64, 1, 28, 28)
print(lot.shape) # torch.Size([64, 1, 28, 28])
print(lot.dtype) # torch.float32
print(lot.device) # cpu

Contrairement à NumPy, PyTorch ne promeut pas silencieusement un int64 en float32. Une addition entre un compteur entier et une matrice de poids lève une erreur explicite. C'est volontaire : sur GPU, une promotion invisible coûte cher et se remarque rarement. On convertit alors explicitement avec .to(torch.float32) ou .float().

Formes : view, reshape et la contiguïté

Réorganiser une image aplatie de 784 pixels en 28 par 28 semble anodin. Deux fonctions coexistent, et l'échec de l'une explique l'existence de l'autre.

x = torch.arange(784)
img = x.view(28, 28) # vue partagée, sans copie
img = x.reshape(28, 28) # copie si nécessaire, sinon vue

view exige un tenseur contigu en mémoire et renvoie une vue partagée. Une transposition ou un permute casse la contiguïté ; un appel à view derrière lève alors RuntimeError: view size is not compatible .... reshape regarde la contiguïté et copie si besoin — plus tolérant, mais parfois plus coûteux. Quand la copie n'est pas souhaitée, appeler .contiguous() d'abord rend le comportement prévisible.

Pour la boucle d'entraînement du module 5, on aplatira l'image 28 par 28 en un vecteur de taille 784 avant de la passer à une couche linéaire : img_lot.view(img_lot.size(0), -1). Le -1 demande à PyTorch de déduire la dimension restante.

La diffusion, comme dans NumPy, avec les mêmes règles

PyTorch applique la diffusion (broadcasting) quand les formes ne correspondent pas : les dimensions manquantes sont ajoutées à gauche, et les dimensions de taille 1 sont étirées à la taille de l'autre opérande.

lot = torch.randn(64, 1, 28, 28)
moyenne = torch.tensor([0.2860]) # forme (1,)
ecart = torch.tensor([0.3530]) # forme (1,)
lot_normalise = (lot - moyenne) / ecart # diffusion sur toutes les dims

Cet exemple normalise Fashion-MNIST en un seul passage : la moyenne et l'écart-type des pixels du jeu, tous deux scalaires, sont étirés implicitement sur la forme (64, 1, 28, 28). C'est la formulation qu'on retrouvera dans le module 4 sous forme de transformation.

Le piège numéro un des débutants : les opérations en place

Toute méthode dont le nom se termine par un souligné modifie le tenseur sur place. add_, mul_, zero_, copy_ sont les plus courantes.

w = torch.randn(3, requires_grad=True)
w.add_(0.1) # remplace w par w + 0.1, sans créer de nouveau tenseur

Sur un tenseur ordinaire, l'opération en place est plus économe en mémoire. Sur un tenseur qui participe à un graphe de calcul (requires_grad=True ou intermédiaire d'un calcul suivi), elle peut casser la rétropropagation en écrasant une valeur nécessaire au calcul du gradient. PyTorch lève parfois une exception explicite, parfois pas — c'est le module 2 qui explique précisément pourquoi.

Règle de sécurité

Dans le corps d'un forward, préférez toujours la forme fonctionnelle qui produit un nouveau tenseur : x = x + biais plutôt que x.add_(biais). Réservez les opérations en place aux boucles utilitaires manifestement hors du graphe, comme l'initialisation ou la mise à jour manuelle d'un tampon.

Un premier lot de Fashion-MNIST

Voici la mise en place minimale du jeu de données. On y reviendra en détail au module 4 ; ici, on se contente d'observer les formes.

from torchvision import datasets, transforms

transformation = transforms.Compose([
transforms.ToTensor(), # image PIL -> tenseur (1, 28, 28) dans [0, 1]
transforms.Normalize((0.2860,), (0.3530,)),
])

train = datasets.FashionMNIST(
root="./donnees", train=True, download=True, transform=transformation
)

image, etiquette = train[0]
print(image.shape, image.dtype, image.device) # torch.Size([1, 28, 28]) torch.float32 cpu
print(etiquette) # entier entre 0 et 9

Chaque exemple est déjà un tenseur float32 normalisé. L'étiquette, elle, est un entier Python que le DataLoader du module 4 empilera en un long de forme (N,). Retenir : les entrées d'un modèle sont en float32, les cibles de classification en long.

En résumé

  • Trois attributs décrivent un tenseur : forme, type et dispositif ; la dimension de tête est celle du lot, et PyTorch ne promeut jamais silencieusement long en float.
  • torch.tensor copie, torch.from_numpy partage la mémoire : utilisez .clone() quand vous voulez découpler les deux structures.
  • view exige la contiguïté, reshape copie si nécessaire ; après une transposition, contiguous() rend le comportement prévisible.
  • Les opérations en place (add_, mul_, _) économisent la mémoire mais peuvent casser l'autograd : préférez la forme fonctionnelle dans le corps d'un forward.

Le module suivant explique le mécanisme qui, à partir d'un tenseur et d'un peu de code Python, calcule automatiquement les dérivées dont l'entraînement a besoin.