Bienvenue de nouveau ! Dans la Partie 1, nous avons vu comment un réseau de neurones pouvait additionner deux nombres en utilisant une structure feedforward basique. À la fin de l'article, nous vous avons même montré une implémentation complète en C++ qui incluait le passage avant, la boucle d'entraînement, le calcul de la perte et les mises à jour des poids — tout en code.

Cela nous amène naturellement à la question suivante : comment un réseau de neurones apprend-il à ajuster ces poids par lui-même au cours de l'entraînement ? C'est exactement ce que nous allons explorer ici dans la Partie 2.

Avant d'aller plus loin, voici le code C++ complet de la Partie 1 qui implémente un réseau de neurones simple pour apprendre à additionner deux nombres. Il inclut le passage avant, le calcul de la perte, la rétropropagation et les mises à jour des poids :

#include <iostream>
#include <cmath>
using namespace std;

// Fonction d'activation sigmoid
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Dérivée de la sigmoid (utilisée dans la rétropropagation)
float sigmoid_derivative(float y) {
    return y * (1 - y); // où y = sigmoid(x)
}

int main() {
    // Initialiser les poids et biais pour 3 nœuds cachés
    // Ces valeurs sont choisies manuellement pour montrer le processus d'apprentissage plus clairement.
    // Utiliser à la fois des poids positifs et négatifs aide à prévenir la symétrie et fournit un point de départ diversifié.
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;      // Le nœud 1 commence avec une influence positive de deux entrées
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;    // Le nœud 2 commence avec une influence négative, encourageant l'équilibre
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;   // Le nœud 3 est mixte, utile pour capturer les interactions

    // Initialiser les poids et le biais de la couche de sortie
    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000; // Nombre de fois que nous parcourons les données d'entraînement

    // Boucle d'entraînement
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // Normaliser la somme cible à [0, 1]

                // ===== Passage Avant =====
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error; // Erreur au carré (perte)

                // ===== Rétropropagation - Couche de Sortie =====
                float delta_out = error; // Dérivée de la perte par rapport à y_pred
                float grad_out_w1 = delta_out * h1; // Gradient pour le poids de sortie 1
                float grad_out_w2 = delta_out * h2; // Gradient pour le poids de sortie 2
                float grad_out_w3 = delta_out * h3; // Gradient pour le poids de sortie 3
                float grad_out_b = delta_out;       // Gradient pour le biais de sortie

                // ===== Rétropropagation - Couche Cachée =====
                // Calculer comment chaque nœud caché a contribué à l'erreur
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1; // Gradient pour w1 du nœud1
                float grad_w2_node1 = delta_h1 * x2; // Gradient pour w2 du nœud1
                float grad_b_node1 = delta_h1;       // Gradient pour le biais du nœud1

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // ===== Mettre à Jour les Poids de la Couche de Sortie =====
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                // ===== Mettre à Jour les Poids de la Couche Cachée =====
                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }
        // Journaliser la perte tous les 100 époques
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Résumé] Époque " << epoch + 1 << ": Perte = " << total_loss << endl;
    }

    // ===== Tester le modèle avec une entrée utilisateur =====
    float a, b;
    cout << "\nTester la prédiction de somme (a + b)\nEntrer a : ";
    cin >> a;
    cout << "Entrer b : ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Passage avant à nouveau avec les poids entraînés
    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    // Sortie du résultat
    cout << "Somme prédite : " << predicted_sum << "\nSomme réelle : " << (a + b) << endl;
    return 0;
}

Dans cette partie, nous allons décomposer comment un réseau de neurones apprend : de la configuration des couches et des fonctions d'activation à l'ajustement des poids par la rétropropagation. Vous apprendrez également comment nous entraînons le modèle en utilisant des taux d'apprentissage et des époques — et explorer si un réseau peut aller au-delà de tâches simples comme l'addition.

  • Ce que font réellement les nœuds et les couches dans un réseau de neurones.
  • Comment nous définissons les poids initiaux et les ajustons pendant l'entraînement.
  • Le rôle du taux d'apprentissage et des époques dans la formation du processus d'apprentissage.
  • Ce que sont les fonctions d'activation, comment elles fonctionnent, et quand utiliser sigmoid, tanh ou ReLU.
  • Comment la rétropropagation et les dérivées permettent au réseau d'apprendre de ses erreurs.
  • Si un réseau de neurones peut aller au-delà de tâches simples comme l'addition et apprendre plusieurs choses.

Prêt ? Allons-y !

Nœuds et Couches : Que se passe-t-il vraiment ?

Un neurone (ou nœud) est le bloc de construction de base d'un réseau de neurones. Chaque neurone reçoit des valeurs d'entrée, les multiplie par des poids, ajoute un biais, puis passe le résultat à travers une fonction d'activation pour produire une sortie. C'est comme un petit décideur qui transforme les données étape par étape.

Une couche est une collection de neurones qui opèrent au même niveau du réseau. L'information circule d'une couche à l'autre. Les trois types principaux de couches sont :

  • Couche d'entrée : reçoit les données brutes (par exemple, les nombres que vous souhaitez additionner).
  • Couches cachées : effectuent des transformations sur l'entrée, apprenant des motifs à travers des connexions pondérées.
  • Couche de sortie : produit la prédiction ou le résultat final.

Chaque neurone dans une couche est connecté aux neurones de la couche suivante, et chacune de ces connexions a un poids que le réseau apprend et ajuste pendant l'entraînement.

Pensez à chaque nœud (neurone) dans un réseau de neurones comme à un petit décideur. Il prend quelques entrées, les multiplie par ses "poids", ajoute un "biais", puis applique une fonction d'activation (comme sigmoid, tanh ou ReLU).

Plus de nœuds = plus de puissance cérébrale :

  • Un petit nombre de nœuds pourrait rapidement résoudre des problèmes simples.
  • Plus de nœuds signifient que votre réseau peut comprendre des motifs plus complexes, mais trop de nœuds peuvent le ralentir ou le faire trop réfléchir (surajuster) !

Que se passe-t-il si nous empilons des couches ? Vous pouvez tout à fait empiler plusieurs couches cachées — c'est un "réseau de neurones profond". Chaque couche apprend quelque chose de légèrement plus abstrait :

  • La première couche apprend des motifs simples.
  • La deuxième couche combine ces motifs en idées plus complexes.
  • Les troisième et couches suivantes construisent une compréhension de plus en plus abstraite.

Poids et Biais dans les Réseaux de Neurones

Qu'est-ce qu'un poids dans un réseau de neurones ?

Dans un réseau de neurones, un poids est une valeur qui représente la force de la connexion entre deux neurones. Lorsque les données d'entrée circulent à travers le réseau, elles sont multipliées par ces poids. Un poids plus élevé signifie que l'entrée a une influence plus forte sur la sortie du neurone suivant. Pendant l'entraînement, ces poids sont ajustés pour aider le réseau à faire de meilleures prédictions.

Qu'est-ce qu'un biais dans un réseau de neurones ?

Un biais est un paramètre supplémentaire dans un réseau de neurones qui permet de décaler l'activation d'un neurone. Il agit comme un décalage ou un seuil qui aide le modèle à mieux s'adapter aux données en lui permettant d'apprendre des motifs qui ne passent pas par l'origine. Tout comme les poids, les biais sont ajustés pendant l'entraînement pour améliorer les prédictions.

Comment définissons-nous les poids de départ ?

Lorsque nous commençons l'entraînement, les poids doivent être initialisés à certaines valeurs. Dans notre exemple de code, nous les avons simplement définis manuellement au départ :

float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;

Dans des systèmes plus complexes, les poids sont généralement initialisés aléatoirement en utilisant de petites valeurs (comme entre -0.5 et 0.5). Ce caractère aléatoire aide à prévenir la symétrie — où tous les neurones apprennent la même chose — et donne au réseau une meilleure chance de découvrir des motifs utiles. Les stratégies d'initialisation courantes incluent Xavier (Glorot) et He initialisation, qui sont conçues pour maintenir un signal stable à mesure qu'il circule à travers le réseau. Pour des expériences simples comme la nôtre, des valeurs manuelles ou aléatoires petites fonctionnent suffisamment bien.

Comment ajustons-nous les poids ?

Pendant l'entraînement, voici comment fonctionne l'ajustement :

  1. Le réseau fait une prédiction en utilisant les poids actuels.
  2. Il compare la prédiction avec la réponse correcte (sortie cible).
  3. Il calcule à quel point il était éloigné — c'est l'erreur ou la "perte".
  4. Il utilise la rétropropagation pour calculer comment chaque poids a affecté l'erreur.
  5. Chaque poids est ajusté légèrement pour réduire l'erreur la prochaine fois — cette étape est réalisée à l'aide de gradients (dérivées) et d'un taux d'apprentissage.

Dans notre exemple de code C++, ce processus se déroule à l'intérieur des boucles d'entraînement imbriquées. Voici une référence rapide :

float error = target - y_pred;
float delta_out = error;
float grad_out_w1 = delta_out * h1;
float grad_out_w2 = delta_out * h2;
float grad_out_w3 = delta_out * h3;
float grad_out_b = delta_out;

float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

float grad_w1_node1 = delta_h1 * x1;
float grad_w2_node1 = delta_h1 * x2;
float grad_b_node1 = delta_h1;

// Mettre à jour les poids
weight_out_node1 += learning_rate * grad_out_w1;
weight_out_node2 += learning_rate * grad_out_w2;
weight_out_node3 += learning_rate * grad_out_w3;
bias_out += learning_rate * grad_out_b;

weight1_node1 += learning_rate * grad_w1_node1;
weight2_node1 += learning_rate * grad_w2_node1;
bias_node1 += learning_rate * grad_b_node1;

Ce snippet démontre comment nous calculons les gradients et les appliquons pour mettre à jour les poids dans les couches de sortie et cachées. Chaque itération utilise les gradients calculés à partir de la dérivée de la fonction d'activation et de l'erreur pour pousser les poids dans la direction qui réduit la perte. Au fil de milliers d'itérations, les poids deviennent progressivement plus précis.

Fonctions d'Activation : Donner de la personnalité à vos nœuds

Les fonctions d'activation décident si un neurone doit "s'activer" ou non. Elles sont appliquées au résultat de la somme pondérée et du biais dans chaque nœud, introduisant non-linéarité dans le réseau — sans cela, le réseau ne pourrait apprendre que des relations linéaires (pas très utiles pour des problèmes complexes).

Voici quelques fonctions d'activation courantes :

1. Sigmoid

  • Formule:   1 / (1 + e^(-x))
  • Plage de sortie: 0 à 1
  • Cas d'utilisation: Idéal pour la classification binaire ou les couches de sortie où vous avez besoin de valeurs entre 0 et 1.
  • Avantages: Sortie lisse et bornée.
  • Inconvénients: Sature aux extrêmes (près de 0 ou 1), ralentit l'apprentissage en raison des gradients qui disparaissent.

2. Tanh

  • Formule(e^x - e^(-x)) / (e^x + e^(-x))
  • Plage de sortie: -1 à 1
  • Cas d'utilisation: Lorsque vous souhaitez une sortie centrée sur zéro.
  • Avantages: Gradients plus forts que la sigmoid ; apprentissage plus rapide dans les couches cachées.
  • Inconvénients: Souffre toujours des gradients qui disparaissent aux extrêmes.

3. ReLU (Unité Linéaire Rectifiée)

  • Formule:   max(0, x)
  • Plage de sortie: 0 à l'infini.
  • Cas d'utilisation: Par défaut pour la plupart des couches cachées en apprentissage profond.
  • Avantages: Calcul rapide, aide avec l'activation sparse.
  • Inconvénients: Peut mourir si les entrées sont toujours négatives ("problème de ReLU mourant").

Quand utiliser quelle fonction d'activation ?

ActivationPlage de SortieBon PourType de Couche CouranteRemarques
Sigmoid0 à 1Sortie BinaireCouche de sortiePeut causer un gradient qui disparaît
Tanh-1 à 1Couches cachées centrées sur zéroCouche cachéeGradients plus forts que la sigmoid
ReLU0 à ∞La plupart des réseaux profonds et des CNNCouche cachéeRapide, efficace, mais peut "mourir" sur des négatifs

Dans notre exemple de code, nous avons utilisé sigmoid pour les couches cachées. Cela fonctionne pour de petites démonstrations, mais dans des réseaux plus grands, ReLU est souvent préféré pour de meilleures performances et un entraînement plus rapide.

Dérivées et Rétropropagation : Comment les réseaux apprennent de leurs erreurs

Imaginez que vous êtes aveugle, debout sur une colline, et que vous devez trouver votre chemin en descendant. Vous toucheriez le sol avec votre pied pour sentir quelle direction descend, puis vous vous déplaceriez en conséquence.

C'est ce que font les réseaux de neurones en utilisant des dérivées — ils vérifient quels petits ajustements rendent les prédictions plus précises.

Après avoir fait une prédiction, le réseau calcule à quel point il s'est trompé (nous appelons cela "perte"). Il utilise ensuite quelque chose appelé rétrorépropagation pour retracer cette erreur à travers le réseau, déterminant exactement comment chaque poids a contribué à l'erreur.

Voici la boucle de rétropropagation simplifiée :

  1. Passage Avant : Deviner la réponse.
  2. Calculer la Perte : À quel point cette devinette était-elle erronée ?
  3. Passage Arrière : Découvrir comment chaque poids a affecté cette erreur.
  4. Mettre à Jour les Poids : Ajuster légèrement les poids pour réduire les erreurs futures.

Ce processus se répète des milliers de fois, rendant progressivement le réseau de plus en plus intelligent.

Voyons une implémentation C++ simplifiée de cette idée :

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

C'est notre fonction d'activation et sa dérivée. Elles sont utilisées à la fois dans les passages avant et arrière.

La boucle d'entraînement utilise deux entrées (a et b), les normalise entre 0 et 1, et entraîne le réseau à prédire leur somme. Nous utilisons 3 nœuds cachés et 1 nœud de sortie. Voici une partie clé de la boucle :

float error = target - y_pred;
total_loss += error * error;

float delta_out = error;
...
float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);

C'est ici que nous calculons l'erreur et utilisons les dérivées pour pousser les ajustements à travers le réseau — la rétropropagation en action.

À la fin de l'entraînement, nous permettons aux utilisateurs d'entrer deux nombres, et le réseau prédit la somme. Ce n'est pas de la mémorisation — c'est de la généralisation à partir de ce qu'il a appris.

Taux d'Apprentissage et Époques

Comme vous l'avez peut-être remarqué dans le code, nous utilisons deux hyperparamètres importants pour contrôler le processus d'entraînement : learning_rate et époques.

Qu'est-ce qu'un taux d'apprentissage ?

Le taux d'apprentissage détermine la taille de chaque pas lors de la mise à jour des poids. Un petit taux d'apprentissage (comme 0.01) signifie que le réseau fait de petits ajustements à chaque fois, ce qui est plus sûr mais plus lent. Un grand taux d'apprentissage pourrait accélérer les choses, mais s'il est trop grand, le réseau pourrait dépasser et ne pas apprendre correctement.

Dans notre code, nous utilisons :

float learning_rate = 0.01f;

Cela nous donne un équilibre entre vitesse et stabilité.

Qu'est-ce qu'une époque ?

Une époque est un passage complet à travers l'ensemble du jeu de données d'entraînement. Dans notre exemple, pour chaque époque, nous entraînons le réseau en utilisant chaque combinaison de a et b de 0 à 99. Ensuite, nous le faisons à nouveau pour l'époque suivante.

Nous nous entraînons sur plusieurs époques pour permettre au réseau de raffiner ses poids encore et encore.

  • Si vous augmentez le nombre d'époques, le réseau a plus de chances de s'améliorer, ce qui peut réduire encore la perte. Cependant, trop d'époques peuvent conduire à un surajustement, où le modèle devient trop adapté aux données d'entraînement et performe mal sur de nouvelles entrées.
  • Si vous réduisez le nombre d'époques, l'entraînement sera plus rapide, mais le réseau pourrait ne pas apprendre suffisamment et pourrait sous-performer.

Dans le code, nous utilisons :

int epochs = 1000;

C'est généralement un bon départ pour des problèmes simples comme apprendre l'addition, mais vous pouvez ajuster en fonction de la rapidité ou de la lenteur de la diminution de la perte.

Un réseau de neurones peut-il apprendre plusieurs choses ?

Absolument. Les réseaux de neurones ne sont pas limités à apprendre un seul type de tâche. En fait, la même architecture de réseau peut être entraînée pour faire une variété de choses — comme reconnaître des chiffres manuscrits, traduire des langues, ou même générer de la musique — selon les données qui lui sont fournies et comment il est entraîné.

Dans notre exemple, le réseau a appris à additionner deux nombres, mais avec des données d'entraînement différentes et une couche de sortie modifiée, il pourrait également apprendre à :

  • Soustraire des nombres
  • Classer des images
  • Prédire des valeurs futures dans une série temporelle

Plus vous voulez que le réseau gère de tâches — ou plus la tâche est complexe — plus vous aurez besoin de neurones et de couches. Un problème simple comme l'addition pourrait ne nécessiter que quelques nœuds, tandis que des tâches comme la reconnaissance d'images ou la traduction de langues peuvent nécessiter des réseaux beaucoup plus profonds et plus larges. Gardez juste à l'esprit : plus de complexité n'est pas toujours mieux — ajouter trop de nœuds peut conduire à un surajustement, où le réseau mémorise plutôt qu'il n'apprend à généraliser.

Voici la version améliorée de notre réseau de neurones où nous l'entraînons à additionner et soustraire des nombres d'entrée en utilisant un réseau partagé unique :


#include <iostream>
#include <cmath>
using namespace std;

// Fonction d'activation sigmoid (écrase l'entrée dans la plage [0, 1])
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Dérivée de la sigmoid (utilisée pour calculer les gradients pendant la rétropropagation)
float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    // === Poids et Biais de la Couche Cachée (3 neurones cachés, 2 entrées chacun) ===
    float w1_n1 = 0.5f, w2_n1 = 0.5f, b1 = 0.0f;
    float w1_n2 = -0.5f, w2_n2 = -0.5f, b2 = 0.0f;
    float w1_n3 = 0.25f, w2_n3 = -0.25f, b3 = 0.0f;

    // === Poids et Biais de la Couche de Sortie (2 neurones de sortie : somme et soustraction) ===
    float out_w1_sum = 0.5f, out_w2_sum = 0.5f, out_w3_sum = 0.5f, out_b_sum = 0.0f;
    float out_w1_sub = -0.5f, out_w2_sub = -0.5f, out_w3_sub = 0.5f, out_b_sub = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    // === Boucle d'Entraînement ===
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;

        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                // === Normaliser les valeurs d'entrée ===
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;

                // === Normaliser les cibles ===
                float target_sum = (a + b) / 200.0f;          // La somme varie de 0 à 198 → [0, ~1]
                float target_sub = (a - b + 100.0f) / 200.0f; // La soustraction varie de -99 à +99 → [0, ~1]

                // === Passage Avant (Entrée → Couche Cachée) ===
                float z1 = x1 * w1_n1 + x2 * w2_n1 + b1;
                float h1 = sigmoid(z1);

                float z2 = x1 * w1_n2 + x2 * w2_n2 + b2;
                float h2 = sigmoid(z2);

                float z3 = x1 * w1_n3 + x2 * w2_n3 + b3;
                float h3 = sigmoid(z3);

                // === Passage Avant (Couche Cachée → Couche de Sortie) ===
                float y_pred_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
                float y_pred_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

                // === Calculer la Perte (Erreur au Carré) ===
                float error_sum = target_sum - y_pred_sum;
                float error_sub = target_sub - y_pred_sub;
                total_loss += error_sum * error_sum + error_sub * error_sub;

                // === Rétropropagation - Couche de Sortie (Gradients pour chaque nœud de sortie) ===
                float delta_out_sum = error_sum;
                float grad_out_w1_sum = delta_out_sum * h1;
                float grad_out_w2_sum = delta_out_sum * h2;
                float grad_out_w3_sum = delta_out_sum * h3;
                float grad_out_b_sum = delta_out_sum;

                float delta_out_sub = error_sub;
                float grad_out_w1_sub = delta_out_sub * h1;
                float grad_out_w2_sub = delta_out_sub * h2;
                float grad_out_w3_sub = delta_out_sub * h3;
                float grad_out_b_sub = delta_out_sub;

                // === Rétropropagation - Couche Cachée ===
                float delta_h1 = (delta_out_sum * out_w1_sum + delta_out_sub * out_w1_sub) * sigmoid_derivative(h1);
                float delta_h2 = (delta_out_sum * out_w2_sum + delta_out_sub * out_w2_sub) * sigmoid_derivative(h2);
                float delta_h3 = (delta_out_sum * out_w3_sum + delta_out_sub * out_w3_sub) * sigmoid_derivative(h3);

                float grad_w1_n1 = delta_h1 * x1;
                float grad_w2_n1 = delta_h1 * x2;
                float grad_b1 = delta_h1;

                float grad_w1_n2 = delta_h2 * x1;
                float grad_w2_n2 = delta_h2 * x2;
                float grad_b2 = delta_h2;

                float grad_w1_n3 = delta_h3 * x1;
                float grad_w2_n3 = delta_h3 * x2;
                float grad_b3 = delta_h3;

                // === Mettre à Jour les Poids de Sortie ===
                out_w1_sum += learning_rate * grad_out_w1_sum;
                out_w2_sum += learning_rate * grad_out_w2_sum;
                out_w3_sum += learning_rate * grad_out_w3_sum;
                out_b_sum += learning_rate * grad_out_b_sum;

                out_w1_sub += learning_rate * grad_out_w1_sub;
                out_w2_sub += learning_rate * grad_out_w2_sub;
                out_w3_sub += learning_rate * grad_out_w3_sub;
                out_b_sub += learning_rate * grad_out_b_sub;

                // === Mettre à Jour les Poids Cachés ===
                w1_n1 += learning_rate * grad_w1_n1;
                w2_n1 += learning_rate * grad_w2_n1;
                b1 += learning_rate * grad_b1;

                w1_n2 += learning_rate * grad_w1_n2;
                w2_n2 += learning_rate * grad_w2_n2;
                b2 += learning_rate * grad_b2;

                w1_n3 += learning_rate * grad_w1_n3;
                w2_n3 += learning_rate * grad_w2_n3;
                b3 += learning_rate * grad_b3;
            }
        }

        // === Imprimer la Perte Tous les 100 Époques ===
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Époque " << epoch + 1 << "] Perte : " << total_loss << endl;
    }

    // === Phase de Test ===
    float a, b;
    cout << "\nTester la prédiction\nEntrer a : ";
    cin >> a;
    cout << "Entrer b : ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Passage avant à nouveau pour l'entrée utilisateur
    float h1 = sigmoid(x1 * w1_n1 + x2 * w2_n1 + b1);
    float h2 = sigmoid(x1 * w1_n2 + x2 * w2_n2 + b2);
    float h3 = sigmoid(x1 * w1_n3 + x2 * w2_n3 + b3);

    float y_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
    float y_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

    // Dénormaliser les sorties
    float predicted_sum = y_sum * 200.0f;
    float predicted_sub = y_sub * 200.0f - 100.0f;

    cout << "Somme prédite :        " << predicted_sum << endl;
    cout << "Somme réelle :           " << (a + b) << endl;
    cout << "Différence prédite : " << predicted_sub << endl;
    cout << "Différence réelle :    " << (a - b) << endl;

    return 0;
}

Conclusion

C'était beaucoup à assimiler — mais plutôt génial, non ? Nous n'avons pas seulement parlé de théorie ; nous avons vu un réseau de neurones apprendre à faire quelque chose de réel : additionner et soustraire des nombres. Pas de règles codées en dur — juste des poids qui s'ajustent d'eux-mêmes grâce à l'entraînement. En cours de route, nous avons découvert les taux d'apprentissage, les fonctions d'activation, et la magie de la rétropropagation qui relie le tout.

Dans la prochaine partie, nous allons prendre du recul par rapport au code et plonger dans l'histoire derrière tout cela. D'où viennent les réseaux de neurones ? Pourquoi l'IA a-t-elle explosé ces dernières années ? Qu'est-ce qui a changé ? Nous explorerons comment tout a commencé — et pourquoi ce n'est que le début.