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 copieAprè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.
| Attribut | Ce qu'il porte | Erreur typique |
|---|---|---|
shape | dimensions, dans l'ordre (N, C, H, W) par défaut | oublier la dimension de lot en tête |
dtype | float32 par défaut, float16 ou bfloat16 en précision mixte | mélanger long et float dans une opération |
device | cpu ou cuda:0 | additionner 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.
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
longenfloat. torch.tensorcopie,torch.from_numpypartage la mémoire : utilisez.clone()quand vous voulez découpler les deux structures.viewexige la contiguïté,reshapecopie 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'unforward.
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.