Licence CC 0

Et si on crée un réseau de neurones supervisé ?

Perceptron par ici et Perceptron par là

Nous allons créer ensemble un réseau de neurones dit supervisé. Cela signifie que nous allons créer ensemble une intelligence artificielle en lui donnant des exemples pour en attendre des prédictions.

Je précise bien évidemment que ceci est un exemple pour illustrer le fonctionnement d’un algorithme de machine learning superviser mais qu’il restera très simple et ne correspond pas réellement à ce qu’on trouverait dans de vraies applications de ML (qui aurait plusieurs millions de données)

Pour la suite, vous aurez besoin de Python et de numpy (la connaissance de Python est cruciale en data science, un cours sur Python 3 existe sur ZesteDeSavoir et je vous le recommande fortement). Il faudra également un bon bagage mathématique pour comprendre réellement comment le système fonctionne.

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

Un réseau de neurones artificiels est en fait inspirer du fonctionnement des neurones biologiques

Réseau de neurones

Les cercles représentent les neurones et les traits reliant les cercles représentent des synapses

En pratique, chaque synapse comporte un poids généralement entre 0 et 1 ou parfois entre -1 et 1. Nous en reparlerons plus tard dans le billet.

Nous allons dans l’ordre :

  • Multiplier nos valeurs d’entrées par le poids des synapses

  • Utiliser une fonction d’activation (en l’occurrence la fonction sigmoïde qui est certaines l’une des plus simples)

  • Calculer notre marge d’erreur (la fonction sigmoïde va ressortir notre valeur finale, nous pourrons ainsi comparer cette valeur avec la valeur que nous devions obtenir et nous pourrons récupérer notre erreur puis nous pourrons utiliser l’algorithme du gradient que nous reverrons également plus loin dans le billet)

  • Modifier le poids des synapses pour obtenir des valeurs différentes

  • Entrainer notre réseau en répétant les mêmes actions plusieurs fois.

Rappel

Fonction sigmoïde : f(x) = 1 / (1 + e^-x)

Exemple concret :

Avant
Avant

Nous avons ici un réseau de neurones composé de :

  • Deux cercles étant les entrées
  • Trois cercles étant la couche cachés (nos neurones). (une convention indique qu’un moyen simple de déterminer le nombre de neurones est de faire nombre d’entrée + 1)
  • Un cercle qui est la sortie

Entamons la propagation avant (multiplication des entrées avec les poids et mise en place de la fonction d’activation)

Nous pouvons observer que nos deux entrées sont respectivement 4 et 7 et que nos poids sont bien reliés à une synapse par neurone

Début des calculs :

(4 x 0.4) + (7 x 0.3) = 3.7

(4 x 0.8) + (7 x 0.9) = 9.5

(4 x 0.7) + (7 x 0.2) = 4.2

On applique maintenant à chacune des valeurs la fonction d’activation (sigmoïde dans notre cas) :

1 / (1 + e^ - 3.7) = 0.9758

1 / (1 + e^ - 9.5) = 0.9999

1 / (1 + e^ - 4.2) = 0.9852

Maintenant, nous allons faire le calcul pour obtenir notre sortie/ dans un premier temps, on fait une multiplication de chaque valeur de nos neurones par le poids de la synapse reliant à la sortie et on additionne le tout :

(0.9758 x 0.2) + (0.9999 x 0.6) + (0.9852 x 0.8) = 1.58

On applique la fonction sigmoïde :

1 / (1 + e^ - 1.58) = 0.8292

Schéma final :

Après
Après

Vous n’avez strictement rien compris ? Ce n’est pas grave, cet exemple n’est pas du tout un cas concret puisque nous ne savons pas à quoi correspondent nos valeurs d’entrées et nos valeurs de sorties

Notre cas d'étude

Partons d’un exemple simple, je suis agriculteur et j’annote chaque jour la longueur et la largeur d’une fraise et si la fraise est mûre ou non.

(1 = Oui) (0 = Non)

Longeur Largeur Mûre ?
3 1.5 1
2 1 0
4 1.5 1
3 1 0
3.5 0.5 1
2 0.5 0
5.5 1 1
1 1 0
4.5 1 ?

Malheureusement, je n’ai pas l’information de si la dernière fraise que j’ai mesurée est mûre ou non. L’intelligence artificielle va régler le problème.

import numpy as np 

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

main()

Dans un premier temps, on définit dans x nos longueurs et largeur et dans y si la fraise est mûre ou non.

On remarque que le premier tableau comporte une valeur de plus que le second tableau (y) simplement par ce que la dernière valeur est justement celle que l’on souhaite prédire.

Ensuite, on va remettre à l’échelle nos valeurs pour être comprise entre 0 et 1 :

import numpy as np 

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

    x = x / np.amax(x, axis=0) # on met à l'échelle nos valeurs pur être comprise entre 0 et 1

main()

Output de x :

[[0.54545455 1.        ]
 [0.36363636 0.66666667]
 [0.72727273 1.        ]
 [0.54545455 0.66666667]
 [0.63636364 0.33333333]
 [0.36363636 0.33333333]
 [1.         0.66666667]
 [0.18181818 0.66666667]
 [0.72727273 1.        ]

Cependant, on ne souhaite pas prendre en compte la dernière valeur du tableau x pour l’entrainement donc on met à jour le code en créant deux nouvelles variables : les valeurs d’entrainement et la valeur à prédire :

import numpy as np 

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

    x = x / np.amax(x, axis=0) # on met à l'échelle nos valeurs pur être comprise entre 0 et 1
    x_without_last = np.split(x, [8])[0] # Données d'entrainement 
    x_predict = np.split(x, [8])[1] # Valeur qu'on  veut prédire

main()

Démarrons maintenant notre classe de réseau de neurones :

import numpy as np 

class Perceptron:
    def __init__(self):
        self.input = 2 # Nombre d'entrée
        self.output = 1 # Nombre de valeur de sortie
        self.hidden = 3 # Nombre de neurones 

        self.W1 = np.random.randn(self.input, self.hidden) # On génere les poids dans une matrice entre les synapses d'entrées et les synapses cachées (Matrice 2x3)
        self.W2 = np.random.randn(self.hidden, self.output)# On génere les poids dans une matrice entre les synapses cachées et les synapses de sorties (Matrice 3x1)

    # On fais les calculs vu dans le premier chapitre avec la multiplication des entrées des synapses et la fonction sigmoide
    def forward(self, x): 
        self.z = np.dot(x, self.W1) 
        self.z2 = self.sigmoid(self.z) 
        self.z3 = np.dot(self.z2, self.W2)
        ouput = self.sigmoid(self.z3)
        return ouput

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

    x = x / np.amax(x, axis=0) # on met à l'échelle nos valeurs pur être comprise entre 0 et 1
    x_without_last = np.split(x, [8])[0] # Données d'entrainement 
    x_predict = np.split(x, [8])[1] # Valeur qu'on  veut prédire

    neural = Perceptron()  

    neural_output = neural.forward(x_without_last) # On lui demande de faire un entrainement
    
    print(f"Véritable sortie : {str(y)}")
    print(f"Sortie de l'IA : {str(neural_output)}")  

main()

Output :

Véritable sortie : [[1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]]
Sortie de l'IA : [[0.35937394]
 [0.30732319]
 [0.35134519]
 [0.30089774]
 [0.24037145]
 [0.25008343]
 [0.27535478]
 [0.31144955]]

Le code est commenté et je vous invite donc à le lire et revoir la première section si certaines choses vous semblent un peu floues

On peut observer qu’il y a un problème avec nos valeurs de prédiction et c’est tout à fait normal !

Par ce que nos poids ont était définis de manière complètement aléatoire.

La suite de notre découverte va donc d’être de modifier nos poids.Pour ce faire nous allons faire de la back propagation à l’aide d’une fonction objectif

La backpropagation

Nous allons devoir calculer de combien notre réseau est loin d’une réponse juste. Il faudra ainsi faire la soustraction de la somme que l’on souhaite avoir et la somme qui nous est donnée.

Suivant le résultat on va ajuster le poids des synapses pour s’approcher de leurs valeurs réels.

On souhaite donc savoir comment modifier le poids des synapses et pour cela on va utiliser l’algorithme du gradient.

Les étapes :

  • Calculer la marge d’erreur (input - output = erreur)
  • On applique la dérivée de la sigmoide à l’erreur (erreur delta)
  • Multiplication matricielle entre W2 et l’erreur delta (erreur_couche_2)
  • On applique la dérivée de la sigmoide à l’erreur_couche_2 (erreur_couche_2_delta)
  • Ajuste W1 et W2

Le code :


import numpy as np 

class Perceptron:
    def __init__(self):
        self.input = 2 # Nombre d'entrée
        self.output = 1 # Nombre de valeur de sortie
        self.hidden = 3 # Nombre de neurones 

        self.W1 = np.random.randn(self.input, self.hidden) # On génere les poids dans une matrice entre les synapses d'entrées et les synapses cachées (Matrice 2x3)
        self.W2 = np.random.randn(self.hidden, self.output)# On génere les poids dans une matrice entre les synapses cachées et les synapses de sorties (Matrice 3x1)

    # On fais les calculs vu dans le premier chapitre avec la multiplication des entrées des synapses et la fonction sigmoide
    def forward(self, x): 
        self.z = np.dot(x, self.W1) 
        self.z2 = self.sigmoid(self.z) 
        self.z3 = np.dot(self.z2, self.W2)
        ouput = self.sigmoid(self.z3)
        return ouput

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))

    def sigmoidP(self, x):
        return x * (1 - x)

    # x = valeurs d'entrées
    # y = valeur qu'on doit avoir
    # o = sortie
    def backward(self, x, y, o): 
        self.ouput_error = y - o 
        self.ouput_delta = self.ouput_error * self.sigmoidP(o)
        self.z2_error = self.ouput_delta.dot(self.W2.T)
        self.z2_delta = self.z2_error * self.sigmoidP(self.z2)
        self.W1 += x.T.dot(self.z2_delta)
        self.W2 += self.z2.T.dot(self.ouput_delta)

    def fit(self, x, y):
        ouput = self.forward(x) 
        self.backward(x, y, ouput)

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

    x = x / np.amax(x, axis=0) # on met à l'échelle nos valeurs pur être comprise entre 0 et 1
    x_without_last = np.split(x, [8])[0] # Données d'entrainement 
    x_predict = np.split(x, [8])[1] # Valeur qu'on  veut prédire

    neural = Perceptron()  

    for i in range(50):
        neural_output = np.matrix.round(neural.forward(x_without_last), 2)
        print(f"Véritable sortie : {str(y)}")
        print(f"Sortie de l'IA : {str(neural_output)}")  
        neural.fit(x_without_last, y)

main()

Ouput (dernière tour de boucle) :

Véritable sortie : [[1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]]
Sortie de l'IA : [[0.52]
 [0.46]
 [0.57]
 [0.52]
 [0.53]
 [0.45]
 [0.62]
 [0.39]]

On observe que notre IA est toujours un peu bête puisqu’elle se trompe à chaque fois !

Pour une raison simple ! Nous n’avons que 50 itérations, il en faudrait beaucoup plus (30 000 par exemple). Plus il y aura d’itération et plus on gagnera en précision.

D’ailleurs, notre code est presque finis, nous allons ajouter une fonction de prédiction pour avoir un affichage et savoir si notre fraise est mûre ou non !

import numpy as np 

class Perceptron:
    def __init__(self):
        self.input = 2 # Nombre d'entrée
        self.output = 1 # Nombre de valeur de sortie
        self.hidden = 3 # Nombre de neurones 

        self.W1 = np.random.randn(self.input, self.hidden) # On génere les poids dans une matrice entre les synapses d'entrées et les synapses cachées (Matrice 2x3)
        self.W2 = np.random.randn(self.hidden, self.output)# On génere les poids dans une matrice entre les synapses cachées et les synapses de sorties (Matrice 3x1)

    # On fais les calculs vu dans le premier chapitre avec la multiplication des entrées des synapses et la fonction sigmoide
    def forward(self, x): 
        self.z = np.dot(x, self.W1) 
        self.z2 = self.sigmoid(self.z) 
        self.z3 = np.dot(self.z2, self.W2)
        ouput = self.sigmoid(self.z3)
        return ouput

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))

    def sigmoidP(self, x):
        return x * (1 - x)

    # x = valeurs d'entrées
    # y = valeur qu'on doit avoir
    # o = sortie
    def backward(self, x, y, o): 
        self.ouput_error = y - o 
        self.ouput_delta = self.ouput_error * self.sigmoidP(o)
        self.z2_error = self.ouput_delta.dot(self.W2.T)
        self.z2_delta = self.z2_error * self.sigmoidP(self.z2)
        self.W1 += x.T.dot(self.z2_delta)
        self.W2 += self.z2.T.dot(self.ouput_delta)

    def fit(self, x, y):
        ouput = self.forward(x) 
        self.backward(x, y, ouput)

    def predict(self, x_predict):
        print(f"Entrée : \n ${str(x_predict)}")
        print(f"Sortie : \n ${str(self.forward(x_predict))}")

        if (self.forward(x_predict) < 0.5):
            print("La fraise n'est pas mûre \n")
        else:
            print("La fraise est mûre \n")

def main():
    x = np.array(([3, 1.5], [2, 1], [4, 1.5], [3, 1], [3.5,0.5], [2,0.5], [5.5,1], [1,1], [4,1.5]), dtype=float) # données d'entrer
    y = np.array(([1], [0], [1],[0],[1],[0],[1],[0]), dtype=float) # données de sortie 

    x = x / np.amax(x, axis=0) # on met à l'échelle nos valeurs pur être comprise entre 0 et 1
    x_without_last = np.split(x, [8])[0] # Données d'entrainement 
    x_predict = np.split(x, [8])[1] # Valeur qu'on veut prédire

    neural = Perceptron()  

    for i in range(30000):
        neural_output = np.matrix.round(neural.forward(x_without_last), 2)
        print(f"Véritable sortie : {str(y)}")
        print(f"Sortie de l'IA : {str(neural_output)}")  
        neural.fit(x_without_last, y) 
    
    neural.predict(x_predict)

main()

Sortie (dernier tour de boucle) :

Véritable sortie : [
 [1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]
 [1.]
 [0.]]
Sortie de l'IA : [
 [0.65]
 [0.  ]
 [0.98]
 [0.4 ]
 [0.72]
 [0.  ]
 [1.  ]
 [0.  ]]
Entrée :
 $[[0.72727273 1.        ]]
Sortie :
 $[[0.98698701]]
La fraise est mûre

C’est bien la sortie que j’attendais ! Je sais maintenant que ma fraise est mûre miam !


Je ferais bientôt d’autres billets sur le machine learning si le sujet vous intéresses !

8 commentaires

Bonjour,
Merci pour ce billet assez instructif pour quelqu’un qui code déjà mais qui est néophyte dans le fonctionne des réseaux de neurones :)

Dans la partie backpropagation, la 3ème étape :

  • Multiplication matricielle entre W2 et l’erreur delta (erreur_couche_2)

Tu ne parles pas de W2 auparavant, qu’est-ce ?

merci d’avance :)

Tu ne parles pas de W2 auparavant, qu’est-ce ?

Drulac

Ce sont les poids sur les synapses entre la couche des neurones cachés et la couche de sortie d’après les commentaires dans le code.

Merci d’ailleurs de me renseigner sur l’utilité d’un tel billet. Je le trouvais pas assez pédagogue pour un total débutant, et trop vague pour quelqu’un du domaine.

Je lui reproche tout de même de rester trop en surface du problème, mais j’en demande peut-être trop étant moi-même compétent dans le domaine.

+2 -0

Hello rayandfz,

Tu mentionnes lors de l’apprentissage:

Plus il y aura d’itération et plus on gagnera en précision.

Mais est-ce bien vrai ? N’a-t-on pas des risques de surapprentissage et d’avoir un réseau de neurones qui est beaucoup trop spécifique aux données d’apprentissage ?

+3 -0

Mais est-ce bien vrai ? N’a-t-on pas des risques de surapprentissage et d’avoir un réseau de neurones qui est beaucoup trop spécifique aux données d’apprentissage ?

Effectivement, il y a bien des risques de surapprentissage qui pourrait s’avérer dangereux pour des données différentes (Il vaut mieux y aller à tatillon et ne pas mettre d’office un nombre d’itérations trop élevé)

+1 -0

On est d’accord. Mais alors je trouve ça dommage de dire dans le billet que plus d’itération amène plus de précision alors que dans les faits, c’est plus subtil que ça.

Comme le mentionnait Melcore, si ce billet est destiné à des débutants, ça m’a l’air "dangereux" de leur dire que plus d’itérations = mieux sans donner un avertissement que ça peut avoir des effets négatifs.

Précis et juste sont deux choses différentes, cela dit. Si les poids convergent vers certaines valeurs, il y a bien augmentation de la précision (dans le sens où deux réalisations vont converger vers les même valeurs et donc fournir les mêmes prédictions) même si la justesse n’est pas forcément au rendez-vous.

+3 -0

Il me semble que la croyance populaire autour des réseaux de neurones maintenant est que (au moins pour des grands modèles, mais assez universellement) quand on augmente le nombre d’itérations, l’erreur de test/généralisation diminue, augmente, puis diminue de nouveau ("double descent"). Je ne sais pas si c’est un phénomène qu’on peut mettre en évidence sur des petits modèles comme celui-ci.

Il me semble que la croyance populaire autour des réseaux de neurones maintenant est que (au moins pour des grands modèles, mais assez universellement) quand on augmente le nombre d’itérations, l’erreur de test/généralisation diminue, augmente, puis diminue de nouveau ("double descent"). Je ne sais pas si c’est un phénomène qu’on peut mettre en évidence sur des petits modèles comme celui-ci.

Lucas-84

Pas toujours vrai, de mémoire ça ne concerne que les réseaux qui sont avec des mécanismes d’attention ou des CNNs (qui ont une notion assez proche ou un neurone d’une couche éloignée pourra "voir" une grande partie de l’image (dans le cas d’une image), ce qui n’est pas si éloigné que ça d’un réseau d’attention). De plus dans certains cas les CNNs peuvent être lents à converger, un des cas les plus communs c’est quand tu cherches à te passer de traitement du signal, ton réseau peut finir par apprendre à décomposer le signal. Cela prends du temps à converger mais une fois cela atteins tu auras une deuxième chute (plus tardive) dans l’erreur de test/généralisation.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte