Cours Python · Module 9: Machine learning
Neural Networks - les réseaux de neurones
Dans cette leçon6
Tu possèdes déjà deux piliers de PyTorch : le tensor qui calcule sur GPU et autograd qui dérive à ta place. Il manque le modèle. On peut l'assembler à mains nues - garder les poids dans une liste et multiplier des matrices dans une boucle - mais arrivé à la dixième couche, tu perds le compte comme en dénombrant un troupeau de buffles à la jumelle. Le module torch.nn fournit les briques toutes prêtes : des couches, des fonctions d'activation et des fonctions de perte.
Un modèle personnalisé est une classe qui hérite de nn.Module
Un modèle de réseau de neurones personnalisé se définit dans PyTorch comme une classe héritant de nn.Module et implémentant la méthode forward(). Trois autres idées circulent, autant les écarter tout de suite.
On ne décrit pas une architecture via des fichiers JSON - le JSON transporte des données, mais il n'exécutera pas la moindre multiplication de matrices. Dans PyTorch, un réseau est du code Python.
Tu ne le construis pas davantage en utilisant uniquement des listes et des dictionnaires. Si les poids reposaient dans une liste ordinaire, model.parameters() n'aurait rien à renvoyer, l'optimiseur recevrait une collection vide et l'entraînement se déroulerait sans le moindre message d'erreur, sauf que le modèle ne bougerait pas d'un pas. C'est nn.Module qui enregistre les couches comme des paramètres à apprendre.
Il n'est enfin pas question d'importer depuis une API externe un réseau tout fait : une telle API renvoie la réponse du modèle de quelqu'un d'autre, alors que toi, tu construis le tien, entraîné sur tes propres données. Voici la forme complète, celle que tu reprendras dans l'exercice.
1import torch
2import torch.nn as nn
3import torch.optim as optim
4
5class SafariClassifier(nn.Module):
6 def __init__(self, input_size, hidden_size, num_classes):
7 super(SafariClassifier, self).__init__()
8 self.hidden = nn.Linear(input_size, hidden_size)
9 self.relu = nn.ReLU()
10 self.output = nn.Linear(hidden_size, num_classes)
11
12 def forward(self, x):
13 x = self.hidden(x)
14 x = self.relu(x)
15 x = self.output(x)
16 return x
17
18model = SafariClassifier(input_size=10, hidden_size=64, num_classes=3)La classe a deux parties. Dans __init__, tu déclares de quoi le réseau est fait, et super().__init__() doit y figurer - sans cette ligne, les couches ne sont pas enregistrées. Dans forward(), tu décris par où les données circulent. Cette méthode, tu ne l'appelles jamais directement : tu écris model(x).
nn.Linear(10, 64) est une couche dense - elle reçoit 10 nombres, en rend 64 et détient elle-même la matrice de poids. Pour une simple pile de couches il existe le raccourci nn.Sequential, mais tu n'y écriras aucun embranchement : un modèle personnalisé au flux non standard revient toujours à nn.Module.
L'ordre de traversée du réseau
Les données suivent un ordre fixe : Couche d'entrée (Input Layer) → Couche cachée (Hidden Layer) → Fonction d'activation (ReLU) → Couche de sortie (Output Layer).
La couche d'entrée n'est pas un objet à part - c'est le tensor qui entre dans le modèle, large de input_size, ici les 10 mesures relevées sur un animal pisté. La couche cachée mélange ces nombres et en produit 64 nouveaux. La fonction d'activation se place après elle et non avant, parce qu'elle traite son résultat - avant elle, il n'y a encore que des données brutes. La couche de sortie vient en dernier, car elle ramène les 64 nombres à trois, un par classe.
Si l'activation quittait la chaîne, les deux couches linéaires se composeraient en une seule : plus de poids, exactement autant de possibilités.
ReLU, c'est-à-dire max(0, x)
ReLU est une fonction qui renvoie max(0, x) : elle transforme les nombres négatifs en zéro et laisse passer les positifs sans y toucher. Le plus simple est de l'observer sur un court tensor.
1relu = nn.ReLU()
2
3print(relu(torch.tensor([-2.0, -0.5, 0.0, 3.0])))
4# tensor([0., 0., 0., 3.])Les valeurs -2.0 et -0.5 sont ressorties à zéro, et 3.0 est passée intacte. Cette cassure de la courbe en zéro, c'est la non-linéarité - l'unique raison de la présence de ReLU dans un réseau.
Ce n'est en revanche pas une méthode de compression des données : quatre nombres entrent, quatre nombres sortent. Ce n'est pas non plus une fonction de tri des données - l'ordre est resté intact, 3.0 n'a pas sauté en tête de liste. Et ce n'est pas un algorithme de clustering : le clustering, comme KMeans, regroupe des observations entières, alors que ReLU travaille séparément sur chaque nombre pris isolément.
Entraînement : la perte et les cinq étapes de la boucle
Un modèle fraîchement créé a des poids aléatoires. Pour apprendre, il lui faut une mesure de l'erreur (une fonction de perte) et un mécanisme de correction des poids (un optimiseur).
nn.CrossEntropyLoss est la fonction de perte des tâches de classification multi-classes - celles où chaque observation appartient à une seule classe parmi des classes disjointes : lion, éléphant ou zèbre. Le modèle renvoie un nombre par classe, et la perte mesure l'écart avec la véritable étiquette.
On ne l'emploie pas uniquement en régression : en régression tu prédis un nombre, la masse d'un animal par exemple, et tu prends alors nn.MSELoss. Elle ne convient pas non plus dans les tâches de clustering, car celles-ci n'ont pas d'étiquettes, donc rien à quoi comparer une prédiction. Et ce n'est pas un outil pour la génération de texte : elle sait seulement noter l'erreur commise en choisissant une classe parmi plusieurs, elle ne crée rien d'elle-même.
Un piège se cache ici : si tu ajoutes nn.Softmax() à la fin du modèle et que tu passes son résultat à nn.CrossEntropyLoss, tu ne verras aucune erreur - l'entraînement se déroulera simplement moins bien, car cette fonction de perte applique le softmax en interne.
La boucle compte cinq étapes, toujours dans cet ordre : Forward pass - outputs = model(X) → Calculer la perte (loss) → optimizer.zero_grad() → loss.backward() → optimizer.step().
1criterion = nn.CrossEntropyLoss()
2optimizer = optim.Adam(model.parameters(), lr=0.001)
3
4for epoch in range(100):
5 for batch_X, batch_y in train_loader:
6 outputs = model(batch_X) # 1. forward pass
7 loss = criterion(outputs, batch_y) # 2. calculer la perte
8
9 optimizer.zero_grad() # 3. remettre les gradients a zero
10 loss.backward() # 4. calculer les nouveaux gradients
11 optimizer.step() # 5. corriger les poidsLes raisons de cet ordre sont concrètes. D'abord le forward pass, parce que sans prédiction il n'y a rien à comparer à la vérité. Ensuite calculer la perte (loss) - un unique nombre qui dit à quel point le modèle s'est trompé. C'est seulement une fois ce nombre obtenu que tu appelles loss.backward(), qui remonte le calcul à travers le réseau et remplit le champ grad de chaque paramètre.
Le plus intéressant reste la place de optimizer.zero_grad(). PyTorch cumule les gradients par défaut au lieu de les écraser, donc sans remise à zéro tout ce qui vient des pas précédents s'ajoute au pas courant - et l'entraînement ne lèvera aucune exception, le modèle apprendra simplement de plus en plus mal. La remise à zéro doit donc tomber avant loss.backward(). Placée après, elle effacerait les gradients tout juste calculés et optimizer.step() corrigerait les poids de zéro. Là encore, sans la moindre trace dans la console.
L'étape optimizer.step() referme le cycle : elle déplace chaque poids vers une erreur plus faible, autant que lr l'autorise. Remarque que optim.Adam a reçu model.parameters() - la collection rassemblée par nn.Module.
Sauvegarder les poids du modèle
Un entraînement dure des heures, autant en conserver le résultat. On ne sauvegarde pas l'objet modèle entier, mais state_dict() - un dictionnaire des noms de couches et des tensors de leurs poids.
1torch.save(model.state_dict(), 'model.pth')
2
3model = SafariClassifier(10, 64, 3)
4model.load_state_dict(torch.load('model.pth'))
5model.eval()Lue élément par élément, la sauvegarde donne torch, ., save, (, model.state_dict(), ,, 'model.pth', ). Les poids passent en premier, le nom du fichier ensuite - l'ordre inverse est la faute de frappe la plus courante.
La relecture se fait en deux temps : tu crées d'abord un modèle de la même architecture, et seulement ensuite tu y verses les poids via torch.load et load_state_dict - le fichier .pth ne transporte que des nombres et ignore combien de couches compte le réseau. Le model.eval() final bascule le réseau en mode évaluation.
Résumé
- Un modèle personnalisé se définit en héritant de
nn.Moduleet en implémentantforward(). Pas via des fichiers JSON, pas en utilisant uniquement des listes et des dictionnaires, pas en important depuis une API externe. - L'ordre dans le réseau :
Couche d'entrée (Input Layer)→Couche cachée (Hidden Layer)→Fonction d'activation (ReLU)→Couche de sortie (Output Layer). - ReLU renvoie
max(0, x)et introduit de la non-linéarité - ce n'est ni une méthode de compression des données, ni une fonction de tri des données, ni un algorithme de clustering. nn.Linearest une couche dense, etnn.Sequentialn'est qu'un raccourci pour les piles simples.nn.CrossEntropyLosss'utilise dans les tâches de classification multi-classes. La régression passe parnn.MSELoss, le clustering se passe d'étiquettes, et cette fonction ne sert pas à la génération de texte.- La boucle d'entraînement :
Forward pass - outputs = model(X)→Calculer la perte (loss)→optimizer.zero_grad()→loss.backward()→optimizer.step(). - Les gradients se cumulent par défaut, c'est pourquoi l'absence de remise à zéro abîme l'entraînement en silence.
- Sauvegarde des poids :
torch.save(model.state_dict(), 'model.pth'), relecture :load_state_dict.
La prochaine leçon te fera découvrir MLflow, l'outil qui retient les paramètres et les métriques de chaque entraînement, pour qu'après vingt essais tu saches lequel s'en est le mieux sorti. Retiens une chose : un réseau, ce sont des couches et des activations agencées dans forward(), et l'apprentissage, ce sont cinq étapes de boucle répétées des milliers de fois.
Code de cette leçon : main.py
1# ===========================================
2# Labo Safari : Réseau de neurones simple
3# ===========================================
4# Construis un réseau de neurones de base avec PyTorch
5# pour classer les schémas de comportement des animaux du safari.
6
7import torch
8import torch.nn as nn
9import torch.optim as optim
10
11# --- Définir le réseau de neurones ---
12class SafariNet(nn.Module):
13 """Réseau de neurones pour classer le comportement animal."""
14
15 def __init__(self, input_size, hidden_size, num_classes):
16 super(SafariNet, self).__init__()
17 self.layer1 = nn.Linear(input_size, hidden_size)
18 self.relu = nn.ReLU()
19 self.layer2 = nn.Linear(hidden_size, num_classes)
20
21 def forward(self, x):
22 out = self.layer1(x)
23 out = self.relu(out)
24 out = self.layer2(out)
25 return out
26
27# --- Initialiser le modèle ---
28input_size = 4 # features : speed, distance, group_size, hour
29hidden_size = 8 # neurones cachés
30num_classes = 3 # broute, repos, chasse
31
32model = SafariNet(input_size, hidden_size, num_classes)
33print("=== SafariNet Architecture ===")
34print(model)
35
36# --- Fonction de perte et optimiseur ---
37criterion = nn.CrossEntropyLoss()
38optimizer = optim.Adam(model.parameters(), lr=0.01)
39
40# --- Exemple de données d'entraînement ---
41X_train = torch.tensor([
42 [2.5, 0.3, 12, 6], [0.8, 2.1, 1, 14],
43 [3.1, 0.5, 8, 7], [0.3, 3.5, 1, 12],
44], dtype=torch.float32)
45y_train = torch.tensor([0, 1, 0, 1], dtype=torch.long)
46
47# --- Boucle d'entraînement (simplifiée) ---
48print("\n=== Training ===")
49for epoch in range(5):
50 outputs = model(X_train)
51 loss = criterion(outputs, y_train)
52
53 optimizer.zero_grad()
54 loss.backward()
55 optimizer.step()
56
57 print(f"Epoch [{epoch+1}/5], Loss: {loss.item():.4f}")
58
59# TODO : Ajoute une troisième couche cachée au réseau
60# class SafariNetV2(nn.Module):
61# def __init__(self, input_size, hidden1, hidden2, num_classes):
62# super(SafariNetV2, self).__init__()
63# self.layer1 = nn.Linear(input_size, hidden1)
64# self.relu1 = nn.ReLU()
65# self.layer2 = nn.Linear(hidden1, ...)
66# self.relu2 = nn.ReLU()
67# self.layer3 = nn.Linear(..., num_classes)
68#
69# def forward(self, x):
70# out = self.relu1(self.layer1(x))
71# out = self.relu2(self.layer2(out))
72# out = self.layer3(out)
73# return out
74
75# TODO : Lance une inférence sur un nouvel échantillon
76# model.eval()
77# with torch.no_grad():
78# sample = torch.tensor([[2.0, 0.5, 10, 7]], dtype=torch.float32)
79# prediction = model(sample)
80# predicted_class = torch.argmax(prediction, dim=1)
81# labels = ["Grazing", "Resting", "Hunting"]
82# print(f"\nPrediction: {labels[predicted_class.item()]}")
83Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment définit-on un modèle de réseau de neurones personnalisé dans PyTorch ?
2. Qu'est-ce que la fonction d'activation ReLU ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Range les étapes dans le bon ordre :
- Tri vertical
Range les étapes de la boucle d'entraînement dans PyTorch :
- Éditeur de code
Définis un modèle avec des couches Linear et ReLU, puis entraîne-le.
- Clique dans l'ordre
Clique sur les boutons pour sauvegarder les poids du modèle :