---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# PyTorch — tenseurs et autograd

```{code-cell} python
:tags: [hide-input]

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
```

## Pourquoi PyTorch ?

Le paysage du deep learning est dominé par deux grandes bibliothèques : **TensorFlow** (Google) et **PyTorch** (Meta AI, anciennement Facebook). Si TensorFlow a longtemps été privilégié en production, PyTorch s'est imposé comme le standard de la recherche académique et prend une place croissante dans les environnements industriels. Comprendre pourquoi aide à apprécier ses choix de conception.

La caractéristique la plus distinctive de PyTorch est son **graphe de calcul dynamique** (*define-by-run*). À la différence de TensorFlow 1.x, qui demandait de définir le graphe de calcul avant de l'exécuter dans une session séparée, PyTorch construit le graphe à la volée, au moment même où les opérations sont exécutées. Cela rend le code beaucoup plus intuitif : un programme PyTorch ressemble à du Python ordinaire, avec des boucles, des conditions et des appels de fonctions habituels. Le débogage devient immédiat — on peut inspecter les valeurs des tenseurs à n'importe quelle étape en utilisant `print()` ou un débogueur standard.

```{note}
PyTorch est aujourd'hui largement adopté par la communauté scientifique : la grande majorité des articles de recherche en apprentissage profond publiés dans les conférences majeures (NeurIPS, ICML, ICLR) sont accompagnés d'implémentations PyTorch. Des bibliothèques phares comme Hugging Face Transformers, Lightning, TorchVision et TorchAudio reposent toutes sur PyTorch. Sa popularité en recherche se traduit progressivement en adoption industrielle, notamment grâce à TorchScript et ONNX qui permettent d'exporter des modèles vers des environnements de production.
```

```python
# Installation (si nécessaire)
# pip install torch torchvision torchaudio
# ou avec support CUDA :
# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
```

```{code-cell} python
import torch

print(f"Version de PyTorch : {torch.__version__}")
print(f"CUDA disponible    : {torch.cuda.is_available()}")
print(f"MPS disponible     : {torch.backends.mps.is_available()}")

# Sélection automatique du device disponible
if torch.cuda.is_available():
    device = torch.device("cuda")
elif torch.backends.mps.is_available():
    device = torch.device("mps")
else:
    device = torch.device("cpu")
print(f"Device utilisé     : {device}")
```

## Tenseurs

Le **tenseur** est l'objet central de PyTorch. On peut le concevoir comme une généralisation des tableaux NumPy : un tableau multidimensionnel de valeurs numériques homogènes. Un scalaire est un tenseur de rang 0, un vecteur un tenseur de rang 1, une matrice un tenseur de rang 2, et ainsi de suite. Ce qui distingue les tenseurs PyTorch des tableaux NumPy, c'est leur capacité à résider sur un accélérateur matériel (GPU ou Apple Silicon) et à participer automatiquement à la différentiation.

```{admonition} Tenseur PyTorch
:class: tip
Un **tenseur PyTorch** est un tableau multidimensionnel caractérisé par :

- Sa **forme** (*shape*) : un tuple décrivant le nombre d'éléments le long de chaque dimension.
- Son **type de données** (*dtype*) : `torch.float32` (par défaut), `torch.float64`, `torch.int64`, `torch.bool`, etc.
- Son **device** : l'emplacement physique en mémoire — `cpu`, `cuda:0` (premier GPU NVIDIA), `mps` (Apple Silicon).
- Son **`requires_grad`** : booléen indiquant si les opérations sur ce tenseur doivent être enregistrées pour la rétropropagation.
```

### Création de tenseurs

```{code-cell} python
# À partir d'une liste Python
t1 = torch.tensor([1.0, 2.0, 3.0])
print(f"Depuis liste          : {t1}  dtype={t1.dtype}  shape={t1.shape}")

# Tenseurs prédéfinis
t_zeros = torch.zeros(3, 4)
t_ones  = torch.ones(2, 3, 4)
t_eye   = torch.eye(4)
t_rand  = torch.rand(3, 3)       # uniforme sur [0, 1)
t_randn = torch.randn(3, 3)      # normale standard N(0, 1)
t_arange = torch.arange(0, 10, 2, dtype=torch.float32)

print(f"zeros(3,4)  shape : {t_zeros.shape}")
print(f"ones(2,3,4) shape : {t_ones.shape}")
print(f"arange      : {t_arange}")

# Spécifier le dtype et le device
t_float64 = torch.tensor([1.0, 2.0], dtype=torch.float64)
t_int32   = torch.tensor([1, 2, 3], dtype=torch.int32)
t_on_dev  = torch.randn(4, 4, device=device)
print(f"Sur {device} : {t_on_dev.device}")
```

### Conversion depuis NumPy

```{code-cell} python
# NumPy → PyTorch
arr = np.array([[1.0, 2.0], [3.0, 4.0]])
t_from_np = torch.from_numpy(arr)        # partage la mémoire !
t_copy    = torch.tensor(arr)            # copie indépendante

print(f"from_numpy : {t_from_np}")
print(f"Même mémoire (from_numpy) : {t_from_np.data_ptr() == arr.ctypes.data}")

# PyTorch → NumPy (seulement sur CPU)
arr_back = t_from_np.numpy()
print(f"Retour NumPy : {arr_back}")

# Déplacer vers/depuis un device
if device.type != 'cpu':
    t_gpu = t_from_np.to(device)
    t_cpu = t_gpu.cpu()
```

```{note}
`torch.from_numpy()` crée un tenseur qui **partage la mémoire** avec le tableau NumPy sous-jacent : modifier l'un modifie l'autre. C'est efficace mais peut être source de bugs subtils. Pour une copie indépendante, utiliser `torch.tensor(arr)` ou `torch.from_numpy(arr).clone()`. Cette contrainte de mémoire partagée disparaît dès qu'on déplace le tenseur sur un GPU (`.to('cuda')`) puisque la mémoire GPU est distincte de la mémoire CPU.
```

## Opérations sur les tenseurs

PyTorch propose une API très riche d'opérations algébriques, la plupart disponibles aussi bien comme méthodes de tenseur que comme fonctions du module `torch`.

```{code-cell} python
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
b = torch.tensor([[5.0, 6.0], [7.0, 8.0]])

# Arithmétique élément par élément
print("Addition         :", a + b)
print("Multiplication   :", a * b)
print("Division         :", b / a)
print("Puissance        :", a ** 2)

# Produit matriciel
print("\nMatmul (a @ b)   :\n", a @ b)
print("torch.matmul     :\n", torch.matmul(a, b))
```

**`einsum`** est une notation puissante et concise qui unifie de nombreuses opérations tensorielle (transposée, produit matriciel, produit externe, contraction de tenseurs) :

```{code-cell} python
# Produit matriciel avec einsum
print("einsum matmul    :\n", torch.einsum('ij,jk->ik', a, b))

# Trace d'une matrice
print("Trace            :", torch.einsum('ii->', a))

# Produit scalaire de deux vecteurs
v1 = torch.tensor([1.0, 2.0, 3.0])
v2 = torch.tensor([4.0, 5.0, 6.0])
print("Produit scalaire :", torch.einsum('i,i->', v1, v2))

# Produit externe
print("Produit externe  :\n", torch.einsum('i,j->ij', v1, v2))
```

Le **broadcasting** de PyTorch suit les mêmes règles que NumPy — les dimensions sont alignées depuis la droite, et une dimension de taille 1 est étirée automatiquement pour correspondre à l'autre opérande :

```{code-cell} python
# Broadcasting : ajouter un vecteur à chaque ligne d'une matrice
mat = torch.randn(4, 3)
biais = torch.tensor([0.1, 0.2, 0.3])   # shape (3,)
result = mat + biais                      # shape (4, 3)
print(f"mat shape : {mat.shape}, biais shape : {biais.shape} → {result.shape}")

# Opérations in-place (se terminent par _)
x = torch.ones(3)
x.add_(2.0)   # équivalent à x += 2.0, modifie x sur place
print("In-place add_ :", x)
```

```{note}
Les opérations **in-place** (suffixées par `_`) modifient le tenseur directement en mémoire sans allouer un nouveau tenseur. Elles sont plus économes en mémoire, mais **incompatibles avec l'autograd** si le tenseur participe à un graphe de calcul : PyTorch lève une erreur car l'opération in-place détruit des informations nécessaires à la rétropropagation. Il faut donc les éviter sur les tenseurs avec `requires_grad=True`.
```

## Autograd

L'**autograd** est le mécanisme de différentiation automatique de PyTorch. Il permet de calculer les gradients de n'importe quelle expression tensorielle par rapport à ses entrées, sans avoir à dériver les formules à la main. C'est le cœur du deep learning : l'optimisation des paramètres d'un réseau de neurones repose entièrement sur ce calcul automatique des gradients.

```{admonition} Graphe de calcul et rétropropagation
:class: tip
Lorsqu'un tenseur avec `requires_grad=True` participe à des opérations, PyTorch enregistre ces opérations dans un **graphe de calcul orienté acyclique** (DAG). Les noeuds feuilles sont les tenseurs d'entrée, les noeuds internes sont les résultats des opérations. L'appel à `.backward()` sur un tenseur scalaire déclenche la **rétropropagation** : PyTorch parcourt le graphe en sens inverse et accumule les gradients dans l'attribut `.grad` de chaque tenseur feuille avec `requires_grad=True`, en appliquant la règle des dérivées en chaîne.
```

```{code-cell} python
# Exemple simple : z = x² + 2xy, dz/dx = 2x + 2y, dz/dy = 2x
x = torch.tensor(3.0, requires_grad=True)
y = torch.tensor(2.0, requires_grad=True)

z = x**2 + 2 * x * y
print(f"z = x² + 2xy = {z.item()}")

# Rétropropagation
z.backward()

print(f"dz/dx = 2x + 2y = {x.grad.item():.1f}  (attendu : {2*3 + 2*2:.1f})")
print(f"dz/dy = 2x      = {y.grad.item():.1f}  (attendu : {2*3:.1f})")
```

```{code-cell} python
# Accumulation des gradients : les gradients s'accumulent !
x = torch.tensor(1.0, requires_grad=True)

loss1 = x ** 2
loss1.backward()
print(f"Après loss1.backward() : x.grad = {x.grad}")  # 2.0

loss2 = x ** 3
loss2.backward()
print(f"Après loss2.backward() : x.grad = {x.grad}")  # 2.0 + 3.0 = 5.0 !

# Il faut remettre les gradients à zéro avant chaque itération
x.grad.zero_()
loss3 = x ** 2
loss3.backward()
print(f"Après zero_() + loss3  : x.grad = {x.grad}")  # 2.0
```

`torch.no_grad()` désactive l'enregistrement des opérations dans le graphe de calcul. On l'utilise systématiquement lors de l'inférence (évaluation, prédiction) pour économiser de la mémoire et accélérer les calculs :

```{code-cell} python
x = torch.tensor(2.0, requires_grad=True)

# Avec no_grad : pas de graphe de calcul, pas de gradient possible
with torch.no_grad():
    y = x ** 2 + 1
    print(f"y.requires_grad = {y.requires_grad}")  # False

# Hors no_grad : le graphe est construit
y = x ** 2 + 1
print(f"y.requires_grad = {y.requires_grad}")      # True
```

### Visualisation du graphe de calcul et des gradients

```{code-cell} python
:tags: [hide-input]

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# --- Graphe de calcul ---
ax = axes[0]
ax.set_xlim(-1, 5)
ax.set_ylim(-0.5, 5.5)
ax.axis('off')
ax.set_title("Graphe de calcul de z = x² + 2xy", fontsize=12, fontweight='bold')

node_style = dict(boxstyle='round,pad=0.4', facecolor='#dce8f8', edgecolor='#2980b9', lw=2)
op_style   = dict(boxstyle='round,pad=0.4', facecolor='#fde8cc', edgecolor='#e67e22', lw=2)
out_style  = dict(boxstyle='round,pad=0.4', facecolor='#d5f5e3', edgecolor='#27ae60', lw=2)

nodes = {
    'x':   (0.5, 4.5, 'x = 3.0\n(feuille)', node_style),
    'y':   (3.5, 4.5, 'y = 2.0\n(feuille)', node_style),
    'x2':  (0.5, 3.0, 'x²\n= 9.0',          op_style),
    'xy':  (2.5, 3.0, 'x·y\n= 6.0',          op_style),
    '2xy': (3.5, 3.0, '2·(x·y)\n= 12.0',     op_style),
    'z':   (2.0, 1.2, 'z = 21.0\n(sortie)',   out_style),
}
for key, (nx, ny, label, style) in nodes.items():
    ax.text(nx, ny, label, ha='center', va='center', fontsize=9,
            bbox=style, transform=ax.transData)

arrows = [
    ('x',  'x2',  "²"),
    ('x',  'xy',  "×"),
    ('y',  'xy',  "×"),
    ('xy', '2xy', "×2"),
    ('x2', 'z',   "+"),
    ('2xy','z',   "+"),
]
positions = {k: (v[0], v[1]) for k, v in nodes.items()}
for src, dst, lbl in arrows:
    x0, y0 = positions[src]
    x1, y1 = positions[dst]
    ax.annotate('', xy=(x1, y1 + 0.35), xytext=(x0, y0 - 0.35),
                arrowprops=dict(arrowstyle='->', color='#555', lw=1.5))

# Gradients
ax.text(0.5, 2.2, "∂z/∂(x²)=1", ha='center', fontsize=8, color='#c0392b',
        fontstyle='italic')
ax.text(3.0, 2.2, "∂z/∂(2xy)=1", ha='center', fontsize=8, color='#c0392b',
        fontstyle='italic')
ax.text(0.5, 4.0, "∂z/∂x=10", ha='center', fontsize=8, color='#c0392b',
        fontstyle='italic')
ax.text(3.5, 4.0, "∂z/∂y=6", ha='center', fontsize=8, color='#c0392b',
        fontstyle='italic')

# --- Illustration de la rétropropagation ---
ax = axes[1]
ax.set_xlim(0, 6)
ax.set_ylim(0, 5)
ax.axis('off')
ax.set_title("Règle des dérivées en chaîne\n(rétropropagation)", fontsize=12, fontweight='bold')

steps = [
    (3.0, 4.2, "z = x² + 2xy", '#2980b9'),
    (3.0, 3.2, "∂z/∂z = 1", '#27ae60'),
    (3.0, 2.4, "∂z/∂(x²) = 1    ∂z/∂(2xy) = 1", '#e67e22'),
    (3.0, 1.6, "∂z/∂x = 2x·1 + 2y·1 = 2·3 + 2·2 = 10", '#8e44ad'),
    (3.0, 0.8, "∂z/∂y = 2x·1 = 2·3 = 6", '#8e44ad'),
]
for (sx, sy, text, color) in steps:
    ax.text(sx, sy, text, ha='center', va='center', fontsize=10,
            color=color, fontweight='bold' if sy == 4.2 else 'normal',
            bbox=dict(boxstyle='round,pad=0.3', facecolor='white',
                      edgecolor=color, alpha=0.8))

for i in range(len(steps) - 1):
    _, y0, _, _ = steps[i]
    _, y1, _, _ = steps[i + 1]
    ax.annotate('', xy=(3.0, y1 + 0.2), xytext=(3.0, y0 - 0.2),
                arrowprops=dict(arrowstyle='->', color='#555', lw=1.5))

ax.text(3.0, 4.85, "Propagation avant →", ha='center', fontsize=9,
        color='#2980b9', fontstyle='italic')
ax.text(3.0, 0.2, "← Rétropropagation (backward)", ha='center', fontsize=9,
        color='#c0392b', fontstyle='italic')

plt.tight_layout()
plt.show()
```

## `torch.nn.functional`

Le module `torch.nn.functional` (conventionnellement importé sous l'alias `F`) regroupe les fonctions stateless (sans état, sans paramètres apprenables) du deep learning : fonctions d'activation, fonctions de perte, opérations de convolution, etc.

```{admonition} Fonctions d'activation
:class: tip
Les **fonctions d'activation** introduisent la non-linéarité indispensable aux réseaux de neurones profonds. Sans elles, un empilement de couches linéaires resterait une transformation linéaire globale. Les principales sont :

- **ReLU** (*Rectified Linear Unit*) : $f(x) = \max(0, x)$. Simple, efficace, ne souffre pas du problème de gradient qui s'évanouit pour les grandes valeurs positives.
- **Sigmoid** : $f(x) = \frac{1}{1 + e^{-x}}$. Sortie dans $(0, 1)$, utilisée pour les probabilités binaires. Souffre de la saturation pour les grandes valeurs.
- **Softmax** : $f(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}}$. Transforme un vecteur en distribution de probabilité. Utilisée en dernière couche pour la classification multi-classes.
```

```{code-cell} python
import torch.nn.functional as F

x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])

print("ReLU    :", F.relu(x))
print("Sigmoid :", F.sigmoid(x).round(decimals=4))
print("Tanh    :", F.tanh(x).round(decimals=4))

logits = torch.tensor([1.0, 2.0, 3.0])
print("Softmax :", F.softmax(logits, dim=0).round(decimals=4))
```

```{code-cell} python
# Fonctions de perte
y_true = torch.tensor([0, 1, 2, 1])      # classes réelles
logits = torch.randn(4, 3)               # scores bruts du modèle (4 exemples, 3 classes)

# Cross-entropie (combine LogSoftmax + NLLLoss)
loss_ce = F.cross_entropy(logits, y_true)
print(f"CrossEntropy loss : {loss_ce.item():.4f}")

# MSE loss pour la régression
y_pred = torch.tensor([2.5, 0.0, 2.0, 8.0])
y_real = torch.tensor([3.0, -0.5, 2.0, 7.0])
loss_mse = F.mse_loss(y_pred, y_real)
print(f"MSE loss          : {loss_mse.item():.4f}")

# BCE loss pour la classification binaire (après sigmoid)
y_bin = torch.tensor([1.0, 0.0, 1.0])
p_bin = torch.sigmoid(torch.tensor([2.0, -1.0, 0.5]))
loss_bce = F.binary_cross_entropy(p_bin, y_bin)
print(f"BCE loss          : {loss_bce.item():.4f}")
```

```{code-cell} python
:tags: [hide-input]

x_plot = torch.linspace(-4, 4, 200)
activations = {
    'ReLU':    F.relu(x_plot),
    'Sigmoid': F.sigmoid(x_plot),
    'Tanh':    F.tanh(x_plot),
    'ELU':     F.elu(x_plot),
}

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
colors = sns.color_palette("muted", len(activations))

ax = axes[0]
for (name, vals), color in zip(activations.items(), colors):
    ax.plot(x_plot.numpy(), vals.detach().numpy(), label=name, lw=2.5, color=color)
ax.axhline(0, color='black', lw=0.8, ls='--')
ax.axvline(0, color='black', lw=0.8, ls='--')
ax.set_title("Fonctions d'activation", fontsize=13, fontweight='bold')
ax.set_xlabel("x")
ax.set_ylabel("f(x)")
ax.legend()
ax.set_ylim(-1.5, 2.5)

# Gradients des activations
ax = axes[1]
x_grad = x_plot.detach().requires_grad_(True)
for (name, _), color in zip(activations.items(), colors):
    if x_grad.grad is not None:
        x_grad.grad.zero_()
    func_map = {'ReLU': F.relu, 'Sigmoid': F.sigmoid, 'Tanh': F.tanh, 'ELU': F.elu}
    out = func_map[name](x_grad).sum()
    out.backward()
    grad = x_grad.grad.detach().numpy().copy()
    ax.plot(x_plot.numpy(), grad, label=f"∂{name}/∂x", lw=2.5, color=color)
    x_grad.grad.zero_()

ax.axhline(0, color='black', lw=0.8, ls='--')
ax.axvline(0, color='black', lw=0.8, ls='--')
ax.set_title("Dérivées des fonctions d'activation", fontsize=13, fontweight='bold')
ax.set_xlabel("x")
ax.set_ylabel("f'(x)")
ax.legend()
ax.set_ylim(-0.5, 1.5)

plt.tight_layout()
plt.show()
```

## Résumé

Ce chapitre a introduit les fondations de PyTorch :

- **PyTorch** se distingue par son graphe de calcul dynamique (*define-by-run*), qui rend le code intuitif à écrire et à déboguer. Il est devenu le standard de la recherche en deep learning et gagne en popularité dans les environnements de production.
- Un **tenseur** est le type de données central : un tableau multidimensionnel caractérisé par sa forme, son `dtype` et son `device`. Il peut être créé depuis des listes Python, des tableaux NumPy ou des générateurs (`torch.randn`, `torch.zeros`, etc.).
- PyTorch offre une riche palette d'**opérations** : arithmétique élément par élément, produit matriciel (`@`, `matmul`), `einsum` pour les contractions générales, et broadcasting automatique. Les opérations in-place (suffixe `_`) sont pratiques mais incompatibles avec l'autograd.
- L'**autograd** est le mécanisme de différentiation automatique. Il enregistre les opérations dans un graphe orienté acyclique et calcule les gradients par rétropropagation via `.backward()`. `torch.no_grad()` désactive ce mécanisme pour l'inférence.
- `torch.nn.functional` regroupe les fonctions stateless : activations (ReLU, sigmoid, tanh), fonctions de perte (cross-entropie, MSE, BCE). Leurs gradients sont calculés automatiquement par autograd.

Le chapitre suivant s'appuie sur ces fondations pour construire des réseaux de neurones complets à l'aide de `nn.Module`, l'abstraction de haut niveau de PyTorch pour la définition d'architectures.
