Les mathématiques derrière l'IA : Commençons simplement

Comment écririez-vous normalement un programme pour ajouter deux nombres inférieurs à 100 ? Facile, non ? Vous écririez généralement quelque chose de simple comme :

#include <iostream>
using namespace std;

int main() {
    int a, b, sum;

    cout << "Entrez le premier nombre : ";
    cin >> a;

    cout << "Entrez le deuxième nombre : ";
    cin >> b;

    sum = a + b;

    cout << "La somme est : " << sum << endl;

    return 0;
}

Mais prenons une voie plus excitante : apprenons à une machine à apprendre l'addition toute seule, en utilisant un réseau de neurones. Les réseaux de neurones sont au cœur de l'IA moderne, alimentant tout, de la reconnaissance d'images et de la traduction de langues à des systèmes avancés comme ChatGPT.

Dans cet article, nous allons montrer comment un réseau de neurones peut "apprendre" à ajouter deux nombres par l'entraînement, et non en suivant une règle codée en dur. Vous verrez comment un réseau de neurones apprend à "ajouter" en repérant des motifs. Nous allons même révéler les chiffres réels (la magie derrière le rideau) qui rendent cela possible.

Voici un aperçu de la structure interne d'un réseau de neurones entraîné :

// Poids et biais de la couche cachée
double weight1_node1 = 0.658136;
double weight2_node1 = 0.840666;
double bias_node1 = -0.893218;

double weight1_node2 = -0.720667;
double weight2_node2 = -0.369172;
double bias_node2 = 0.036762;

double weight1_node3 = 0.512252;
double weight2_node3 = 0.292342;
double bias_node3 = -0.0745917;

// Poids et biais de la couche de sortie
double weight_out_node1 = 1.93108;
double weight_out_node2 = -0.718584;
double weight_out_node3 = 0.589741;
double bias_out = -0.467899;

À première vue, ces valeurs peuvent sembler aléatoires, mais elles représentent un petit cerveau artificiel capable d'apprendre à ajouter deux nombres.

Utilisez cette formule pour calculer la sortie. Ne vous inquiétez pas si cela semble compliqué pour l'instant — suivez simplement :

Ici, x1 et x2 sont les deux nombres que vous souhaitez ajouter (normalisés en divisant par 100) :

double x1 = 0.3; // 30 / 100
double x2 = 0.5; // 50 / 100

// Couche cachée
double z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
double h1 = sigmoid(z1);

double z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
double h2 = sigmoid(z2);
double z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
double h3 = sigmoid(z3);

// Couche de sortie
double sum = (h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out) * 200.0f;

Décomposons pourquoi nous multiplions par 200 : nous avons initialement mis à l'échelle nos entrées et sorties pour qu'elles se situent entre 0 et 1 afin de faciliter l'entraînement. Étant donné que la somme maximale de deux nombres inférieurs à 100 est 200, nous remettons la sortie à l'échelle en la multipliant par 200.

Fonction sigmoïde :

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

Essayons avec de vrais nombres :

Utilisons x1 = 30 et x2 = 50. Nous les normalisons d'abord :

x1 = 30 / 100 = 0.3
x2 = 50 / 100 = 0.5

Maintenant, nous effectuons les calculs :

z1 = (0.3 × 0.658136) + (0.5 × 0.840666) - 0.893218
   ≈ 0.1974408 + 0.420333 - 0.893218
   ≈ -0.2754442
h1 = sigmoid(z1) ≈ 0.431844

z2 = (0.3 × -0.720667) + (0.5 × -0.369172) + 0.036762
   ≈ -0.2162001 - 0.184586 + 0.036762
   ≈ -0.3640241
h2 = sigmoid(z2) ≈ 0.409872

z3 = (0.3 × 0.512252) + (0.5 × 0.292342) - 0.0745917
   ≈ 0.1536756 + 0.146171 - 0.0745917
   ≈ 0.2252549
h3 = sigmoid(z3) ≈ 0.556078

// Couche de sortie finale
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.834389) + (-0.294320) + (0.328013) - 0.467899
       ≈ 0.400183

Somme prédite = 0.400183 × 200 ≈ 80.0366
// Pourquoi multiplier par 200 ? Parce que pendant l'entraînement, nous avons normalisé toutes les entrées et sorties pour rester entre 0 et 1 afin d'améliorer la stabilité de l'apprentissage. Étant donné que la somme maximale de deux entrées est 100 + 100 = 200, nous remettons la sortie du réseau à l'échelle en la multipliant par 200.

Boom ! Le résultat est d'environ 80, ce qui est la somme correcte de 30 et 50.

Si vous ne croyez pas que ce petit réseau peut vraiment additionner, testons-le à nouveau avec d'autres nombres : 20 et 30.

Nous normalisons d'abord les entrées :

x1 = 20 / 100 = 0.2
x2 = 30 / 100 = 0.3

Ensuite, les calculs :

z1 = (0.2 × 0.658136) + (0.3 × 0.840666) - 0.893218
   ≈ 0.1316272 + 0.2521998 - 0.893218
   ≈ -0.509391
h1 = sigmoid(z1) ≈ 0.375241

z2 = (0.2 × -0.720667) + (0.3 × -0.369172) + 0.036762
   ≈ -0.1441334 - 0.1107516 + 0.036762
   ≈ -0.218123
h2 = sigmoid(z2) ≈ 0.445714

z3 = (0.2 × 0.512252) + (0.3 × 0.292342) - 0.0745917
   ≈ 0.1024504 + 0.0877026 - 0.0745917
   ≈ 0.1155613
h3 = sigmoid(z3) ≈ 0.528860

// Couche de sortie finale
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.724688) + (-0.320095) + (0.311644) - 0.467899
       ≈ 0.248338

Somme prédite = 0.248338 × 200 ≈ 49.6676

Il a prédit environ 49.67. C'est très proche de la somme réelle : 50 !

Plutôt cool, non ?

Alors... Qu'est-ce qu'un réseau de neurones, au fait ?

Introduction rapide : Un réseau de neurones est comme une version mathématique de votre cerveau. Il se compose d'unités appelées neurones qui traitent les entrées en les multipliant par des poids, en ajoutant un biais, puis en appliquant une fonction d'activation. Une fonction d'activation courante est la sigmoïde :

sigmoid(x) = 1 / (1 + e^(-x))

Cela comprime toute entrée en une valeur entre 0 et 1, permettant au réseau de capturer des motifs complexes.

Voici le graphique de la fonction sigmoïde, montrant clairement comment les entrées sont transformées en douceur en valeurs entre 0 et 1 :

Il existe d'autres fonctions d'activation aussi, comme ReLU (Rectified Linear Unit), tanh et softmax, chacune ayant ses propres cas d'utilisation et comportements. Mais la sigmoïde est un excellent point de départ car elle est simple et lisse — parfaite pour de petits réseaux de neurones comme le nôtre.

La configuration : Apprendre à notre réseau à additionner

Pour cette expérience, nous avons créé un petit réseau avec :

  • 2 nœuds d'entrée (pour les deux nombres à ajouter)
  • 3 nœuds cachés (effectuant le travail lourd avec la magie de la sigmoïde)
  • 1 nœud de sortie (nous donnant la somme)

Voici un diagramme montrant la structure du réseau de neurones que nous utilisons pour ajouter deux nombres. Vous pouvez voir comment chaque nœud d'entrée se connecte à tous les nœuds de la couche cachée, comment les poids et les biais sont appliqués, et comment tout cela s'écoule vers la sortie finale :

Et oui, nous avons tout mis à l'échelle. Étant donné que nos nombres sont inférieurs à 100, nous divisons les entrées par 100 et remettons la sortie à l'échelle par 200 à la fin.

Voici le code que nous écrivons pour ajouter deux nombres à l'aide du réseau de neurones :

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

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

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

int main() {
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;

    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;

    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 cible à la plage 0-1

                // Passer en avant pour la couche cachée
                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);

                // Couche de sortie avec activation linéaire
                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;

                // Passer en arrière (couche de sortie - linéaire)
                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;

                // Passer en arrière (couche cachée)
                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;

                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
                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;

                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;
            }
        }

        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Résumé] Époque " << epoch + 1 << ": Perte = " << total_loss << endl;
    }

    // Tester le modèle
    float a, b;
    cout << "\nPrédiction de somme test (a + b)\nEntrez a : ";
    cin >> a;
    cout << "Entrez b : ";
    cin >> b;

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

    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;

    cout << "w1_node1: " << weight1_node1 << ", w2_node1: " << weight2_node1
                             << ", w1_node2: " << weight1_node2 << ", w2_node2: " << weight2_node2
                             << ", w1_node3: " << weight1_node3 << ", w2_node3: " << weight2_node3
                             << ", out_w1: " << weight_out_node1 << ", out_w2: " << weight_out_node2
                             << ", out_w3: " << weight_out_node3 << ", bias_out: " << bias_out
                             << ", bias_node1: " << bias_node1 << ", bias_node2: " << bias_node2 << ", bias_node3: " << bias_node3 << endl;

    cout << "Somme prédite : " << predicted_sum << "\nSomme réelle : " << (a + b) << endl;

    return 0;
}

Alors... Comment a-t-il appris ?

Voici la partie cool : nous n'avons pas codé les règles de l'addition. Nous avons juste donné au réseau une multitude de paires de nombres et leurs sommes, et il a lentement ajusté ces poids et biais jusqu'à ce que les prédictions s'améliorent de plus en plus.

C'est un peu comme un essai-erreur mais plus intelligent — avec une touche de calcul.

A-t-il vraiment appris à additionner ?

Eh bien, pas exactement comme nous. Il ne comprend pas ce que signifie « plus ». Mais il apprend une très bonne approximation. Il voit des motifs dans les nombres et prédit des sommes qui sont souvent exactes.

C'est plus comme apprendre en ressentant des motifs plutôt qu'en connaissant des règles. Si vous êtes curieux, vous pouvez changer les nombres d'entrée et le tester. Plus vous jouez, plus vous comprendrez comment ces petits cerveaux fonctionnent.

Conclusion

Apprendre à un réseau de neurones à ajouter deux nombres peut sembler ridicule au début, mais c'est un excellent moyen de jeter un œil à l'esprit de l'IA. Derrière tout le battage médiatique, ce ne sont que des poids, des biais et un peu de mathématiques intelligentes.

Dans la prochaine partie, nous plongerons plus profondément dans le fonctionnement des réseaux de neurones : nous explorerons les couches, les fonctions d'activation, les dérivées et ce qui se passe réellement pendant l'entraînement.