Réseau de neurones pour fleurs - 1/2

Science
Je voulais créer un réseau de neurones pour reconnaître des fleurs à partir de photos. Finalement c’est tellement intéressant les réseaux de neurones que voici une première partie qui est une introduction légère mais amusante à ce qu’est un réseau de neurones.
Date de publication

4 août 2026

Reconnaître des fleurs / réseau de neurones

import math
import numpy as np
import matplotlib.pyplot as plt

Réseau de neurones

Généralités

On va commencer par le tout début. Intuitivement pour reconnaître une fleur il faut la regarder. Si on a un doute on peut y regarder de plus près en mesurant les dimensions de ses composants. En réalité dans notre cas on va utiliser les pixels de l’image donnée par l’utilisateur pour deviner de quelle fleur il s’agit.

L’ensemble des données qu’on “regarde de plus près” pour distinguer une fleur d’une autre va nous servir d’input. Ce sera x. Ensuite il faut savoir quelle importance on accorde à chaque valeur de x dans la décision qu’on prendra. On appelle ça le poids associé à une valeur, w. Enfin il faut tenir compte de b qui est notre biais.

Au passage c’est amusant comme on en apprend plus sur “comment prendre des décisions” avec cette méthode de poids à donner à chaque élément qui entre en considération dans le choix à faire. J’avais vu quelques reels insta qui parlaient de “la meilleure méthode pour faire un choix” qui décrivaient une matrice de poids pour pondérer chaque élément pour prendre sa décision de la façon la plus optimale possible.

À quoi sert b ?

Si on considère une droite d’équation y=ax on se prive de beaucoup d’autres “droites”. L’idée de rajouter b dans l’équation d’une droite affine est qu’on peut “décaler” la droite et ainsi considérer d’autres cas.

Autrement dit, la seule façon de pouvoir représenter toutes les droites du plan et de n’en exclure aucune c’est de rajouter notre b tout b-tement :)

In fine, notre réseau de neurones va apprendre au fur et à mesure de l’entrainement les poids qui lui permettent de prédire le plus fidèlement possible l’espèce de fleur pour enfin prédire la fleur présentée. La fin de ce notebook sera dédiée à montrer un exemple d’entrainement avec le dataset Iris de scikit.

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

w = 1
x = np.linspace(0, 6, 200)

biais = [0, -1, -3]
couleurs = ["gold", "cornflowerblue", "orangered"]

for b, c in zip(biais, couleurs):
    plt.plot(x, sigmoid(w * x + b), color=c, label=f"b = {b}")

plt.xlabel("longueur du pétale (cm)")
plt.ylabel("sortie du neurone")
plt.title("Effet du biais sur le seuil de bascule")
plt.legend()
plt.show()

On voit bien que la sortie de notre neurone est directement impactée par le biais choisi et que les valeurs que pourront prendre notre sortie en fonction de la longueur du pétale ne sont pas du tout semblables d’un biais b à l’autre.

Concrètement

Un neurone artificiel va recevoir un vecteur d’entrée x et calcule d’abord une somme pondérée : \begin{equation} z = \sum_{i=1}^{n} w_i x_i + b \end{equation}

Cette formule illustre simplement qu’on attribue un poids (de l’importance) à chaque valeur d’entrée pour notre réseau donc dans notre cas, des mesures caractéristiques d’une fleur.

Ensuite on passe ce résultat dans une fonction d’activation. Sans cette activation, empiler des neurones ne servirait à rien : une composition de fonctions linéaires reste une fonction linéaire, donc un réseau à 100 couches sans activation équivaudrait à une seule couche. L’activation est ce qui permet au réseau d’apprendre des relations non linéaires.

Pourquoi une fonction d’activation ?

Imaginons deux couches sans activation :

\begin{align} z_1 = W_1x+b_1 \qquad \qquad z_2 = W_2z_1+b_2 \end{align}

En substituant : z_2 = W_2(W_1x+b_1) + b_2

On pose : W'=W_2W_1 et b'=b_2+W_2b_1

On obtient : $z_2=W’x+b’ $

C’est mauvais, notre z_2 s’écrit en fonction de x donc aucun intérêt… La fonction d’activation casse cette composition en introduisant une non-linéarité entre les couches, donc z_2 n’est plus une fonction affine de x. Le réseau peut alors représenter des frontières de décision courbes, pas seulement des hyperplans.

Deux fonctions d’activation

  • Sigmoïde : \sigma(z) = \frac{1}{1+e^{-z}}
  • ReLU : ReLU(z)=max(0,z)

Ce serait super intéressant de voir quel impact a le choix de la fonction d’activation sur un réseau de neurones !!

Le neurone

def neurone(x, w, b):
    z = b
    for k in range(len(x)):
        z += x[k] * w[k]
    # fonction d'activation
    a = 1 / (1 + np.exp(-z))    
    return a
neurone([1,2], [0.5,-0.3], 0.1)
np.float64(0.5)

On vient de définir notre premier neurone avec cette fonction neurone ! Concrètement, un neurone prend en entrée : - x correspond à des mesures (par exemple le diamètre de l’iris et le nombre de pétales) ; - w correspond aux poids (à l’importance) qu’on donne à chaque valeur de x ; - b notre biais, comme expliqué plus haut.

Le neurone retourne en sortie une valeur qui servira à déterminer le choix de réponse que fera notre réponse.

La couche de neurones

À présent il faut définir une couche de neurones et pour cela on va passer à la dimension supérieure. Notre fonction qu’on appellera couche prend en entrée : - x correspond toujours à nos mesures ; - W correspond cette fois à une matrice de poids où w_{ij} est le poids attribué à la valeur j du neurone i ; - B notre biais qui est maintenant une matrice puisqu’il faut en attribuer un à chaque neurone. Ainsi b_j est le biais du neurone j.

def couche(x, W, B):
    # np.dot pour le produit matriciel et éviter une boucle for -> ça marche aussi avec @
    z = np.dot(W, x) + B
    # toujours notre fonction d'activation
    a = 1 / (1 + np.exp(-z))
    return a
x = [1, 2]
W = [[0.5, -0.3], [0.1, 0.4], [-0.2, 0.6]]
B = [0.1, -0.2, 0.3]
print(couche(x, W, B))
[0.5        0.66818777 0.78583498]

On utilisera W en majuscule car il s’agit maintenant d’une matrice et plus simple d’un vecteur. En effet, pour une couche nous aurons plusieurs neurones et donc des poids propre à chaque neurone.

Si on veut interpréter notre matrice W, on peut dire que : - le premier neurone de notre couche attribue un poids de 0.5 à la première valeur de x et un poids de -0.3 à la deuxième valeur de x ; - le deuxième neurone de notre couche attribue un poids de 0.1… ; - et ainsi de suite.

Concernant la signification de la sortie de la fonction couche, chaque valeur de notre vecteur représente le niveau d’activation d’un neurone. On peut donc savoir quel neurone a joué quel rôle dans la décision au sein d’une couche !

Ça nous servira grandement dans la backpropagation pour modifier les poids et biais attribués dans l’entrainement.

La propagation

Maintenant on introduit la fonction forward pour aller de l’avant. J’ai pas trouvé mieux comme phrase d’accroche…

On va maintenant pouvoir, grâce à notre fonction forward, passer d’une couche à l’autre en récupérant les niveaux d’activation des neurones détecteurs de la première couche pour nourrir la couche de décision, qui nous donnera un score par espèce de fleur candidate.

def forward(x, W1, B1, W2, B2):
    a1 = couche(x, W1, B1)
    a2 = couche(a1, W2, B2)
    return a2
forward([1, 2, 3, 4], [[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3], [4, 4, 4, 4], [5, 5, 5, 5]], [5, 5, 5, 5, 5], [[1, 1, 1, 1, 1], [2, 2, 2, 2, 2], [3, 3, 3, 3, 3]], [3, 3, 3])
array([0.99966465, 0.99999774, 0.99999998])

Ah oui et il faut mesurer si la prédiction est proche de la réalité !

Pour cela, on introduit une fonction cout qui utilise l’erreur quadratique moyenne pour savoir à quel point notre prédiction est proche de la réalité ou non. La fonction cout prend en entrée la prédiction de notre réseau de neurones pred ainsi que ce qu’on attend reel et retourne l’erreur quadratique moyenne grâce à la formule suivante :

\begin{equation} Err = \frac{1}{n} \sum_{i=1}^{n} (pred_{i} - reel_{i})^2 \end{equation}

def cout(pred, reel):
    n = len(reel)
    err = (1 / n) * sum((pred[i] - reel[i])**2 for i in range(n)) 
    return err
pred = [0.1, 0.1, 0.8]
reel = [1, 0, 0]

cout(pred, reel)
0.4866666666666667

Le gradient

Reprenons l’erreur quadratique moyenne introduite plus haut : (1/n) \sum (prédiction - vraie\_valeur)^2. Pour comprendre la descente de gradient sans se noyer dans les couches, on va en isoler un seul terme.

Posons donc C(w) = (w - 3)^2. Ici, w joue le rôle de la sortie prédite, et 3 celui de la vraie valeur attendue : on entraîne w à se rapprocher d’une cible fictive fixée à 3. Le minimum théorique de cette fonction se situe donc en w = 3, exactement là où l’erreur s’annule.

Le gradient n’est pas un outil de mesure indépendant. C’est la dérivée de la fonction de coût, calculée au point où w se trouve actuellement. La fonction de coût dessine la courbe des erreurs possibles, et le gradient en donne la pente locale, exactement là où on se situe. Dans la boucle w = w - n * f(w), f(w) désigne donc cette dérivée, pas le coût lui-même — c’est elle qui indique la direction et l’intensité de la correction à apporter à w.

Près du minimum, l’écart (w - 3) devient minuscule. Et comme la pente 2(w-3) en dépend directement, elle s’aplatit aussi, mécaniquement. Les pas rétrécissent donc naturellement à l’approche de la solution, ce qui explique pourquoi il faut davantage d’epochs pour finir de converger que pour s’en approcher grossièrement.

Cet exemple à un seul poids n’est qu’un cas particulier : dès qu’on passera à un réseau à plusieurs couches, la même logique s’appliquera à chaque poids, seule la dimension change.

def gradient(w, eta, f, epochs):    
    for k in range(epochs):
        w = w - eta * f(w)
    return w
gradient(0, 0.1, lambda w : 2 * (w - 3), 100)
2.9999999993888893

Petite illustration pour comprendre comment on approche la valeur 3 dans notre exemple avec une fonction (w - 3)^2 :

def cout_exemple(w):
    return (w - 3)**2

def gradient_cout(w):
    return 2 * (w - 3)

w = -4.0
eta = 0.1
epochs = 15

trajectoire = [w]
for k in range(epochs):
    w = w - eta * gradient_cout(w)
    trajectoire.append(w)

trajectoire = np.array(trajectoire)
w_range = np.linspace(-5, 11, 200)

plt.plot(w_range, cout_exemple(w_range), label="Fonction de coût")

plt.scatter(trajectoire, cout_exemple(trajectoire), color="crimson", label="Étapes de la descente") #j'adore king crimson donc j'utilise souvent le rouge crimson

plt.xlabel("w (paramètre)")
plt.ylabel("Coût")
plt.title("Descente de gradient sur une fonction de coût")
plt.legend()
plt.show()

La backpropagation

La backpropagation, ou rétropropagation, c’est la technique qui permet de calculer comment chaque poids d’un réseau a contribué à l’erreur finale. Le principe : remonter la chaîne de calculs à l’envers, de la sortie vers l’entrée, en appliquant la règle de la chaîne à chaque étape.

Ici, avec un seul neurone, cette chaîne est courte : w, b \to z \to a \to L. Le mécanisme reste rigoureusement le même que dans un réseau à plusieurs couches, où l’erreur doit simplement traverser bien plus d’étapes avant d’atteindre les premiers poids.

Pourquoi cette fonction

Notre neurone part avec des poids w et b tirés au hasard, donc ses prédictions initiales n’ont aucun sens. Le but de entraine_neurone est de corriger ces deux valeurs, petit à petit, jusqu’à ce que la sortie a colle à la vraie valeur y.

Chaque tour de boucle répète le même geste : regarder ce que prédit le neurone avec ses poids actuels, mesurer à quel point il se trompe, puis ajuster w et b dans la direction qui réduit cette erreur. Le paramètre epochs fixe simplement combien de fois on répète ce geste et comme on l’a vu avec la parabole, plus on s’approche de la bonne réponse, plus les ajustements deviennent fins, donc plus il en faut pour peaufiner le résultat.

Reste à savoir comment calculer, à chaque tour, la direction et l’intensité de cet ajustement. C’est le rôle de grad_w et grad_b, détaillés ci-dessous qui sont les deux sorties concrètes de cette rétropropagation, appliquées à notre neurone unique.

Calcul de grad_w et grad_b

Le coût L ne dépend pas directement de w et b : il faut passer par z puis par a.

z = wx + b \qquad a = \frac{1}{1+e^{-z}} \qquad L = (a - y)^2

  • Dérivées partielles de chaque maillon

\frac{\partial L}{\partial a} = 2(a - y) \qquad \frac{\partial a}{\partial z} = a(1-a) \qquad \frac{\partial z}{\partial w} = x \qquad \frac{\partial z}{\partial b} = 1

  • Application de la règle de la chaîne

\frac{\partial L}{\partial w} = \frac{\partial L}{\partial a} * \frac{\partial a}{\partial z} * \frac{\partial z}{\partial w} = 2(a-y)\,a(1-a)\,x

\frac{\partial L}{\partial b} = \frac{\partial L}{\partial a} * \frac{\partial a}{\partial z} * \frac{\partial z}{\partial b} = 2(a-y)\,a(1-a)

Ces deux expressions correspondent directement à grad_w et grad_b dans le code : seule la dernière étape de la chaîne diffère (x contre 1), puisque z dépend de w et de b différemment.

def entraine_neurone(x, y, w, b, eta, epochs):
    for k in range(epochs):
        z = w * x + b
        a = 1 / (1 + math.exp(-z))

        grad_w = 2 * (a - y) * a * (1 - a) * x
        grad_b = 2 * (a - y) * a * (1 - a)

        w = w - eta * grad_w
        b = b - eta * grad_b

        L = (a - y) ** 2
        
        if k % 10 == 0:
            print(f"epoch {k}: L = {L}, a = {a}")

    return w, b
entraine_neurone(2, 1, 0.1, 0, 0.5, 100)
epoch 0: L = 0.2026494299756621, a = 0.549833997312478
epoch 10: L = 0.012789758486928291, a = 0.8869081855883093
epoch 20: L = 0.006117578641277499, a = 0.9217850484799901
epoch 30: L = 0.003971275232485292, a = 0.9369819451864365
epoch 40: L = 0.002925439919070996, a = 0.9459126639676995
epoch 50: L = 0.0023097273937545183, a = 0.9519403766790197
epoch 60: L = 0.0019052281965053553, a = 0.956351080236673
epoch 70: L = 0.0016196856219379784, a = 0.9597546819873668
epoch 80: L = 0.0014076068454612083, a = 0.9624819130890019
epoch 90: L = 0.0012440118590713951, a = 0.9647294477067427
(1.3663695296410523, 0.6331847648205262)

Neurone into couche

La logique ne change pas : on cherche toujours à corriger des poids en suivant le gradient du coût. Ce qui change, c’est l’échelle. Au lieu d’un seul w et un seul b, on a maintenant une matrice W et un vecteur b, parce que la couche produit plusieurs sorties à la fois (une par neurone). Le calcul se vectorise, mais chaque neurone de la couche suit individuellement le même raisonnement que celui vu précédemment avec entraine_neurone.

Le rôle de delta

delta généralise ce qu’on calculait avant en une seule valeur : c’est maintenant un vecteur, avec une composante par neurone de sortie. Pour un neurone j donné, sa composante vaut :

\delta_j = \frac{\partial L}{\partial z_j} = \frac{2}{n_{out}}(a_j - y_j)\,a_j(1-a_j)

C’est exactement la formule de grad_w dans entraine_neurone, avant la multiplication par x, le facteur \frac{2}{n_{out}} vient simplement de la moyenne dans le coût L = \frac{1}{n_{out}}\sum_j (a_j - y_j)^2. Chaque \delta_j mesure donc à quel point le neurone j s’est trompé, et dans quelle direction.

De delta aux gradients

Pour un neurone j, le poids reliant l’entrée k à ce neurone s’appelle W_{jk}. La règle de la chaîne donne :

\frac{\partial L}{\partial W_{jk}} = \delta_j * x_k \qquad\qquad \frac{\partial L}{\partial b_j} = \delta_j

Rien de neuf ici : \frac{\partial z_j}{\partial W_{jk}} = x_k et \frac{\partial z_j}{\partial b_j} = 1, comme pour le neurone seul. La seule nouveauté, c’est qu’il faut répéter ce calcul pour chaque paire (j, k) et c’est précisément ce que fait le produit extérieur :

\text{grad\_W} = \delta \otimes x \quad\Leftrightarrow\quad \text{grad\_W}_{jk} = \delta_j \, x_k

np.outer(delta, x) construit exactement cette matrice, de forme (n_{out}, n_{in}), la même que W. Quant à grad_b, il vaut directement delta : aucune transformation nécessaire, puisque \frac{\partial z_j}{\partial b_j} = 1 pour chaque neurone.

En résumé

entraine_couche empile donc, en une seule opération matricielle, ce que entraine_neurone faisait pour un seul neurone. delta porte l’erreur de chaque neurone ; l’outer product la distribue vers chaque poids concerné. La mécanique de descente reste identique. On soustrait toujours eta * grad_W et eta * grad_b, seule la dimension du problème a changé.

Remarque. Pourquoi \otimes et pas * ?

* désigne une multiplication élément par élément entre deux objets de même taille. Or delta (taille n_{out}) et x (taille n_{in}) n’ont pas la même dimension : delta * x n’a donc aucun sens ici.

\otimes (que j’ai découvert exactement avant hier) note le produit extérieur (outer product) : il prend deux vecteurs de tailles différentes et produit une matrice, où chaque case (j,k) contient \delta_j \, x_k. C’est un objet différent : deux vecteurs entrent, une matrice sort, d’où une notation distincte. D’ailleurs np.outer(delta, x) calcule exactement cette matrice.

def entraine_couche(x, y, W, b, eta, epochs):
    for k in range(epochs):
        z = np.dot(W, x) + b
        a = 1 / (1 + np.exp(-z))

        n_out = len(y)
        delta = (2 / n_out) * (a - y) * a * (1 - a)

        grad_W = np.outer(delta, x)
        grad_b = delta

        W = W - eta * grad_W
        b = b - eta * grad_b

        L = np.mean((a - y) ** 2)
        
        if k % 10 == 0:
            print(f"epoch {k}: L = {L}")

    return W, b

Empiler deux couches

entraine_couche corrigeait une seule couche, isolée. entraine_reseau en enchaîne deux : une couche cachée, puis une couche de sortie. La vraie nouveauté n’est pas dans les gradients de la couche 2 : grad_W2 et grad_b2 se calculent exactement comme avant, à partir de delta2. Elle se trouve dans le calcul de delta1, là où la chaîne s’allonge d’un maillon.

Pourquoi delta1 ne se calcule pas comme delta2

Pour la couche de sortie, l’erreur \delta_2 se lit directement depuis (a_2 - y) : le coût compare a_2 à y sans intermédiaire. Mais a_1 n’apparaît dans aucune cible connue, sa seule influence sur L passe par a_2. Il faut donc remonter la chaîne :

w_1, b_1 \to z_1 \to a_1 \to z_2 \to a_2 \to L

Chaque neurone caché i contribue à TOUS les neurones de sortie j à la fois, via W_2. Sa part de responsabilité dans l’erreur finale est donc une somme, sur tous les j, du produit entre \delta_j (l’erreur du neurone j) et W_{2,ji} (le poids qui relie le neurone caché i au neurone de sortie j) :

\frac{\partial L}{\partial a_{1,i}} = \sum_j \delta_{2,j} \, W_{2,ji}

En notation vectorielle, cette somme sur tous les j est exactement un produit matrice-vecteur, celui que fait np.dot(W2.T, delta2). La transposée est nécessaire ici : W_2 va de a_1 vers a_2, donc W_2^T fait le chemin inverse, de \delta_2 vers a_1.

Reste à multiplier par la dérivée locale de la sigmoïde, a_1(1-a_1), pour obtenir \delta_1 :

\delta_1 = (W_2^T \, \delta_2) \odot a_1 \odot (1-a_1)

C’est cette étape, précisément, qui porte le nom de rétropropagation : l’erreur mesurée en sortie est renvoyée vers l’arrière, couche par couche, pondérée par les poids qu’elle traverse en chemin.

Et enfin

grad_W1 = np.outer(delta1, x) et grad_b1 = delta1 suivent la même logique que pour n’importe quelle couche isolée, seul l’input change : x plutôt que a1. Un réseau à trois couches ajouterait simplement un maillon de plus : \delta_1 se propagerait à son tour vers un \delta_0, via W_1^T. Le motif se répète, quel que soit le nombre de couches ; seule la longueur de la chaîne remontée change.

def entraine_reseau(x, y, W1, b1, W2, b2, eta, epochs):
    n_out = len(y)

    for k in range(epochs):
        a1 = couche(x, W1, b1) 
        a2 = couche(a1, W2, b2)

        delta2 = (2 / n_out) * (a2 - y) * a2 * (1 - a2) #delta de la sortie
        delta1 = np.dot(W2.T, delta2) * a1 * (1 - a1) #delta de la couche cachée

        grad_W2 = np.outer(delta2, a1) #forme (n_out, n_hidden)
        grad_b2 = delta2

        grad_W1 = np.outer(delta1, x) # forme (n_hidden, n_in)
        grad_b1 = delta1

        W2 = W2 - eta * grad_W2
        b2 = b2 - eta * grad_b2
        W1 = W1 - eta * grad_W1
        b1 = b1 - eta * grad_b1

        L = np.mean((a2 - y) ** 2)
        
        if k % 100 == 0:
            print(f"epoch {k}: L = {L}")

    return W1, b1, W2, b2, L / epochs

Chargement et préparation des données

Cette cellule charge le dataset Iris et prépare tout ce qu’il faut pour entraîner le réseau à deux couches construit précédemment.

  • Les données : load_iris() fournit 150 fleurs, chacune décrite par 4 mesures (longueur et largeur des pétales et des sépales) stockées dans X, et son espèce réelle (0, 1 ou 2) stockée dans Y.

  • Après petit souci : le réseau a 3 neurones de sortie, un par espèce possible. Il faut donc transformer chaque étiquette en vecteur : l’espèce 1 devient [0, 1, 0], par exemple. C’est le rôle de la fonction classe_vers_vecteur.

  • L’initialisation des poids : W1, b1, W2, b2 sont tirés aléatoirement, avec np.random.seed(0) pour que le tirage reste identique à chaque exécution. La couche cachée compte 5 neurones et reçoit les 4 mesures en entrée ; la couche de sortie compte 3 neurones, un par espèce.

  • L’entraînement, en deux temps : on entraîne d’abord sur une seule fleur, X[0], pendant 1000 epochs pour vérifier que la mécanique de gradient fonctionne avant de généraliser. Puis on tente d’entraîner sur les 150 fleurs, une par une, dans une boucle.

def classe_vers_vecteur(Y, n_classes):
    resultat = np.zeros((len(Y), n_classes))
    for i, classe in enumerate(Y):
        resultat[i, classe] = 1
    return resultat
from sklearn.datasets import load_iris

def entraine_epoch(X, Y_onehot, W1, b1, W2, b2, eta):
    total_L = 0
    n = len(X)
    for k in range(n):
        x = X[k]
        y = Y_onehot[k]

        a1 = couche(x, W1, b1)
        a2 = couche(a1, W2, b2)

        n_out = len(y)
        delta2 = (2 / n_out) * (a2 - y) * a2 * (1 - a2)
        delta1 = np.dot(W2.T, delta2) * a1 * (1 - a1)

        grad_W2 = np.outer(delta2, a1)
        grad_b2 = delta2
        grad_W1 = np.outer(delta1, x)
        grad_b1 = delta1

        W2 = W2 - eta * grad_W2
        b2 = b2 - eta * grad_b2
        W1 = W1 - eta * grad_W1
        b1 = b1 - eta * grad_b1

        total_L += np.mean((a2 - y) ** 2)

    return W1, b1, W2, b2, total_L / n


def accuracy(X, Y, W1, b1, W2, b2):
    correct = 0
    for k in range(len(X)):
        a1 = couche(X[k], W1, b1)
        a2 = couche(a1, W2, b2)
        pred = np.argmax(a2) #ça c'était **** parce que c'est pas une liste donc max() ça fonctionne pas et je sais pas pourquoi j'ai mis un temps fouuuu à trouver cette fonction numpy
        if pred == Y[k]:
            correct += 1
    return correct / len(X)

data = load_iris()
X = data.data #150 exemples, 4 features
Y = data.target #0, 1 ou 2
Y_onehot = classe_vers_vecteur(Y, 3)


np.random.seed(0)
W1 = np.random.randn(5, 4) * 0.5
b1 = np.zeros(5)
W2 = np.random.randn(3, 5) * 0.5
b2 = np.zeros(3)

n_epochs = 200
for epoch in range(n_epochs):
    W1, b1, W2, b2, L_moyen = entraine_epoch(X, Y_onehot, W1, b1, W2, b2, eta=0.1)
    if epoch % 20 == 0:
        acc = accuracy(X, Y, W1, b1, W2, b2)
        print(f"epoch {epoch}: L moyen = {L_moyen}, accuracy = {acc}")

print("Accuracy finale :", accuracy(X, Y, W1, b1, W2, b2))
epoch 0: L moyen = 0.18795428791240798, accuracy = 0.3333333333333333
epoch 20: L moyen = 0.1997968367086164, accuracy = 0.3333333333333333
epoch 40: L moyen = 0.044666104967845274, accuracy = 0.6666666666666666
epoch 60: L moyen = 0.01892031592201796, accuracy = 0.76
epoch 80: L moyen = 0.012569368365134037, accuracy = 0.84
epoch 100: L moyen = 0.009951993299900885, accuracy = 0.8733333333333333
epoch 120: L moyen = 0.00865765683854657, accuracy = 0.9133333333333333
epoch 140: L moyen = 0.007924637724344194, accuracy = 0.9133333333333333
epoch 160: L moyen = 0.0074569032202176895, accuracy = 0.9266666666666666
epoch 180: L moyen = 0.007142779899563775, accuracy = 0.94
Accuracy finale : 0.9466666666666667

Lecture des résultats affichés

L’entraînement affiche une ligne toutes les 20 epochs, de la forme :

epoch 0: L moyen = 0.18795428791240798, accuracy = 0.3333333333333333
epoch 20: L moyen = 0.1997968367086164, accuracy = 0.3333333333333333
...
  • L moyen est le coût moyen calculé sur les 150 fleurs, à la fin de l’epoch en cours. Cette valeur doit décroître au fil des epochs parce que c’est le signe que les poids W1, b1, W2, b2 s’ajustent bien dans la bonne direction. Une valeur qui stagne ou remonte signalerait un problème, par exemple un eta trop élevé.

  • accuracy mesure la proportion de fleurs correctement classées, à ce même instant. Contrairement à L moyen, cette valeur ne baisse jamais de façon strictement monotone : elle peut légèrement fluctuer d’une epoch à l’autre, même quand le coût continue de décroître, parce que passer un score de 0.49 à 0.51 change une classification sans que le coût associé varie beaucoup.

La dernière ligne, Accuracy finale, donne le taux de réussite du modèle après les 200 epochs sur les 150 fleurs utilisées pour l’entraînement.

Une nuance importante à garder en tête : ce chiffre mesure à quel point le réseau a bien appris ces fleurs précises, pas sa capacité à généraliser sur des fleurs jamais vues.

Ça signifie une chose : un prochain notebook arrivera pour pousser ce modèle sur des fleurs jamais vues et avec du computer vision pour donner directement les mesures au réseau et pouvoir faire un VRAI détecteur de fleurs !!!! Ce serait trop génial.

La source: Reconnaître des fleurs / réseau de neurones