Deep Learning 01 – Qu’est-ce qu’un neurone artificiel ?

Je vais tenter de démystifier le Deep Learning (Apprentisage Profond) qui est à l’origine des progrès de l’intelligence artificielle ces dernières années.
Cette série d’articles s’adresse à un publique de développeurs sans bagage mathématique.
Le langage utilisé sera le Python 3. L’installation d’Anaconda est conseillée pour bénéficier directement des dépendances nécessaires.
Le Deep Learning repose sur des réseaux de neurones (Neural Network) artificiels.

Le terme de « neurone artificiel » peut faire peur, cependant c’est quelque chose de très simple.

Il est inspiré par les neurones du cerveau humain, qui à partir de signaux extérieurs produisent une sortie.

Voici un exemple de neurone artificiel, appelé « perceptron » :
Perceptron

Source : Wikimedia

Concrètement un perceptron reçoit des valeurs d’entrées, il les multiplie une à une par un poids et en fait la somme.
C’est ce qu’on appelle en terme mathématique un produit scalaire ( https://fr.wikipedia.org/wiki/Produit_scalaire#%C3%89criture_matricielle ).

Cette somme est comparée à un seuil. Si elle est inférieure, la valeur de sortie sera 0, 1 sinon.

On peut donc écrire une fonction pour réaliser cela en Python :

def perceptron(val1, val2):
  poids1 = 2
  poids2 = 1
  poids3 = -3
  seuil = 0
  somme = poids1 * val1 + poids2 * val2 + poids3
  if somme >= seuil:
    return 1
  else:
    return 0

print("perceptron(0,0) : ", perceptron(0,0))
print("perceptron(1,0) : ", perceptron(1,0))
print("perceptron(0,1) : ", perceptron(0,1))
print("perceptron(1,1) : ", perceptron(1,1))

Voici le résultat :

-3
perceptron(0,0) :  0
-1
perceptron(1,0) :  0
-2
perceptron(0,1) :  0
0
perceptron(1,1) :  1

Vous aurez remarquez que le perceptron se comporte comme une porte logique AND.
Mais comment trouver les valeurs des poids qui amènent à ce résultat ?

Laissons donc la machine les découvrir toute seule !

Pour cela nous allons initialiser les poids à 0.

Puis nous ferons une boucle de plusieurs itérations.

A chaque itération nous calculerons la somme des produits entre les poids et les valeurs, comme précédemment. Sauf que nous utiliserons par commodité la fonction « dot » de la librairie NumPy qui permet de faire toute seule le produit scalaire (dot product).

Cette somme sera déduite du résultat attendu, pour calculer la valeur de l’erreur.

On multiplie ensuite cette erreur par les valeurs d’entrée et le taux d’apprentissage pour déterminer les nouveaux poids. C’est l’algorithme de descente de gradient.
Le taux d’apprentissage permet d’ajuster à quelle vitesse on apprend. S’il est trop faible l’apprentissage sera long, s’il est trop élevé on n’arrivera pas à apprendre. Ici nous utiliserons une valeur de 1.

On recommence l’opération un certain nombre de fois.

A la fin on affiche le résultat de chaque valeur d’entrée possible pour vérifier que l’ordinateur a apprit correctement.

Voici le code :

#!/usr/bin/python3.5
# -*- coding: UTF-8 -*-
from random import choice
import numpy as np
# Le jeux de données avec le résultat attentu pour un OR
# [entrée1, entrée2, biais d'activation], résultat attendu
donnees_entrainement_OR = [
    (np.array([0,0,1]), 0),
    (np.array([0,1,1]), 1),
    (np.array([1,0,1]), 1),
    (np.array([1,1,1]), 1),
]

# Le jeux de données avec le résultat attentu pour un AND
donnees_entrainement_AND = [
    (np.array([0,0,1]), 0),
    (np.array([0,1,1]), 0),
    (np.array([1,0,1]), 0),
    (np.array([1,1,1]), 1),
]

# Fonction de pré-activation
# Elle prend en entrée la valeur des poids
# Et les valeurs d''entrée des neurones (les entrée + le biais)
def pre_activation(poids, valeurs_entrees):
    # On réalise le produit scalaire (dot product)
    produit_scalaire = poids.T.dot(valeurs_entrees)
    return produit_scalaire


# Fonction d'activation
def fonction_d_activation(produit_scalaire):
    # On retourne 1 si le produit_scalaire est supérieur ou égal à 0, 0 sinon
    return 1 if produit_scalaire >= 0 else 0

# La prédiction consiste à prévoir la valeur attendue en sortie en fontion
# des valeurs d'entrée et des poids
def faire_une_prediction(poids, valeurs_entrees):
    produit_scalaire = pre_activation(poids, valeurs_entrees)
    prediction = fonction_d_activation(produit_scalaire)
    return prediction

def entrainement_du_model(donnees_entrainement, nombre_epoch=10, taux_apprentissage = 1):
    # Epoch : une epoch est un apprentissage sur le jeux de données complet.
    #   Il en faut plusieurs pour arriver à apprendre correctement
    # Une itération est le passage sur une donnée, il y a donc plusieurs itération par Epoch.
    # Initialisation des 3 valeurs de poids (entrée1, entrée2 et biais)
    poids = np.zeros(3)
    # Initialisation d'un tableau pour stocker l'historique des erreurs
    historique_des_erreurs = []
    # On fixe le nombre d'itération
    #  Avec un gros jeux de données on piocherait aléatoirement
    #  des valeurs. Ici on prend tous le jeux de odonnées à chaque fois.
    nombre_iteration = len(donnees_entrainement)
    for epoch in range(nombre_epoch):
        for i in range(nombre_iteration):
            # On récupére les données d'entrées et le résultat attendue
            # dans le jeux de données d'entrainement
            valeurs_entrees, resultat_attendu = donnees_entrainement[i]
            # On réalise une prédiction :
            #   c'est à dire estimer la valeur attendue en fonction des valeurs d'entrée du neurone
            prediction = faire_une_prediction(poids, valeurs_entrees)
            # On soustrait la valeur prédite à la valeur attendue
            # Ce qui nous donne l'erreur, elle est égale à 0 si la prédiction était bonne
            # Si elle est toujours à 0 c'est que l'apprentissage est terminé (pas fait automatiquement ici)
            erreur = resultat_attendu - prediction
            # On ajoute cette erreur à l'historique des erreurs
            historique_des_erreurs.append(erreur)
            # Cette erreur est multipliée par le taux d'aprentissage et les valeurs d'entrées,
            # pour estimer les poids pour la prochaine itération.
            poids = poids + taux_apprentissage * erreur * valeurs_entrees
            #print("input=", valeurs_entrees, "produit_scalaire=", produit_scalaire, " poids=",poids," resultat_attendu=", resultat_attendu, " fonction_d_activation(produit_scalaire)=", fonction_d_activation(produit_scalaire), " erreur=", erreur)
    print("Erreurs = ", historique_des_erreurs)
    # On affiche le graph de l'évolution de l'erreur
    from pylab import plot, ylim, show
    ylim([-1,1])
    plot(historique_des_erreurs)
    show()
    return poids

def utilisation_du_model(poids, donnees):
    # Utilisation des poids issue de l'entrainement sur les différentes valeurs
    for valeurs_entrees, _ in donnees: # "_" sert à ignorée la dernière colonne du tableau qui contient le résultat attendu.
     prediction = faire_une_prediction(poids, valeurs_entrees)
     print("{} -> {}".format(valeurs_entrees[:2], prediction))

# Choix du jeu de données AND
donnees_entrainement = donnees_entrainement_AND
# Le modèl est la structure du réseau de neurones, ici il n'y a qu'un neurone.
# L'entrainement consiste à calculer/déterminer les poids en fonction du résultat attendu. Cette étape s'appel "train" ou "fit" en anglais

poids_calcules = entrainement_du_model(donnees_entrainement, nombre_epoch=8)
print("poids_calcules pour AND : ", poids_calcules)
utilisation_du_model(poids_calcules, donnees_entrainement)
# Entrainement sur le jeux de donnée OR
donnees_entrainement = donnees_entrainement_OR
poids_calcules = entrainement_du_model(donnees_entrainement, nombre_epoch=8)
print("poids_calcules pour OR : ", poids_calcules)
utilisation_du_model(poids_calcules, donnees_entrainement)

Code source également disponible sur mon GitHub.

Le résultat correspond bien à une opération AND ou OR selon le jeux de données:

Erreurs =  [-1, 0, 0, 1, -1, -1, 0, 1, 0, -1, -1, 1, 0, 0, -1, 1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
poids_calcules pour AND :  [ 2.  1. -3.]
[0 0] -> 0
[0 1] -> 0
[1 0] -> 0
[1 1] -> 1
Erreurs =  [-1, 1, 0, 0, -1, 0, 1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
poids_calcules pour OR :  [ 1.  1. -1.]
[0 0] -> 0
[0 1] -> 1
[1 0] -> 1
[1 1] -> 1

Le nombre d’epoch et le taux d’apprentissage sont appelés les « hyper-paramètres » car ils ne modifient pas le neurone mais la façon dont on va le faire apprendre.

Pour voir leurs effets nous pouvons comparer les courbes de l’historique des erreurs qui sont affiché par ce code :

from pylab import plot, ylim, show
ylim([-1,1])
plot(historique_des_erreurs)
show()

Courbe d'apprentissage

On voit que 5 epochs auraient suffis pour apprendre : 5 fois les 4 valeurs de notre jeux de données = 20 itérations.

Vous devriez constater que plus la valeur du taux_apprentissage est petite, plus notre neurone met de temps à apprendre.

Bravo, vous venez de coder votre premier système d’apprentissage !

Je vous rassure, pour la suite nous allons prendre du recule et utiliser des librairies spécialisées.

Mais je pense qu’il est important de comprendre le fonctionnement interne de base pour démystifier le domaine de l’apprentissage profond (Deep Learning).

Ce que nous avons appris :
L’apprentissage d’un neurone artificiel se résume finalement à trois lignes de code que l’on répète :

  • Pré-activation : le produit scalaire des poids pas la valeur d’entrée : poids.T.dot(valeurs_entrees)
  • Activation : prédiction = 1 if produit_scalaire >= 0 else 0
  • Descente de gradient pour trouver les nouveaux poids : poids = poids + taux_apprentissage * (resultat_attendu – prediction) * valeurs_entrees

D’autre part le même neurone peut être entraîné pour se comporter comme une porte OR ou AND sans modification du code.
C’est une grande force du Deep Learning : il n’y a pas besoin de refaire du code quand un nouveau cas se présente. Nous le verrons plus en détail dans une prochaine partie.

Pour plus de détails je vous conseille la lecture du cours de Prépas Dupuy de Lôme.
Pour aller encore plus loin Thibault Neveu a fait une série de vidéos bien détaillées et en français.

La notion que nous venons de voir date des années 50, elle a mis du temps à donner des résultats, en partie suite à un désamour de la communauté scientifique. Voir un historique complet ici (en anglais).
Par la suite nous allons voir comment on passe d’un neurone à un réseau de neurone, ce qui va permettre à la machine d’apprendre des choses plus complexes qu’une porte logique.