Perceptron : Le premier neurone artificiel

Découvrez le premier neurone artificiel créé en 1943 et comprenez les fondamentaux de l'apprentissage profond. Apprenez le lien entre les neurones biologiques et artificiels.

Le premier neurone artificiel

Le premier neurone artificiel a été créé en 1943 par Warren McCulloch et Walter Pitts. Ils ont proposé un modèle mathématique de neurone biologique qui a été utilisé pour créer un neurone artificiel. Le modèle était basé sur une fonction qui prend les signaux d'entrée et les pondère en fonction de leur importance relative. Les signaux pondérés sont ensuite sommés et la sortie du neurone est déterminée par une fonction d'activation. Cependant, il manquait encore la possibilité de modifier les poids et le biais en fonction des erreurs commises lors de la prédiction de sortie. C'est en 1957 que Frank Rosenblatt a introduit l'algorithme du perceptron, qui permettait d'ajuster les poids et le biais en fonction des erreurs de prédiction.

Nous allons voir dans cet article quel est le lien entre les neurones biologiques et les neurones artificiels. Comment en sommes nous arrivés à développer des programmes qui apprennent grâce à une reproduction de systèmes biologiques.

Modélisation mathématique des neurones biologiques

Le neurone est considéré comme l'unité de calcul de base du cerveau et le système nerveux humain contient plus de 85 milliards de ces cellules. Ces neurones sont interconnectés par près de $10^{15}$ synapses. Les signaux qui arrivent aux neurones sont transmis par les neurotransmetteurs via les synapses et passent ensuite par les dendrites jusqu'au corps du neurone. Les signaux de sortie sont ensuite produits par l'axone du neurone et transmis vers les dendrites des prochains neurones, et ainsi de suite.

schéma d'un neurone naturel (src: Stanford)

Pour modéliser la connexion d'un neurone à d'autres neurones similaires, nous pouvons représenter l'un des signaux reçus par $x_i$. Supposons que nous voulons modéliser un neurone qui est connecté à trois autres neurones avant lui, alors les signaux des trois neurones précédents seraient $x_0$, $x_1$ et $x_2$.

Comme mentionné précédemment, ces signaux sont transmis via une synapse avant d'arriver à la dendrite de notre neurone. Pour modéliser le passage des signaux via les neurotransmetteurs, nous multiplions chaque signal $x_i$ par un poids $w_i$, qui peut modifier positivement ou négativement l'intensité du signal. Donc, pour chaque signal $x_i$, nous avons un poids de synapse $w_i$ que nous multiplions ensemble pour représenter la potentielle altération $x_i \cdot w_i$. Les signaux sont ensuite acheminés vers le corps du neurone pour être additionnés :

$$ \hat{Y} = \sum_{i=0}^{2} (x_i \cdot w_i) + b $$

$$ \hat{Y} = x_0 \cdot w_0 + x_1 \cdot w_1 + x_2 \cdot w_2 + b $$

Le biais $b$ a été ajouté a posteriori pour offrir plus de flexibilité dans la prédiction de sortie, ce qui n'a pas direct de lien avec l'analogie avec les neurones biologiques.

Enfin, le signal résultant du calcul précédent est transmis le long de l'axone vers le prochain neurone en utilisant un potentiel d'action représenté par une fonction d'activation. Traditionnellement, une fonction sigmoïde était utilisée pour déterminer le signal de sortie du neurone, mais d'autres fonctions peuvent être utilisées (nous utiliserons la fonction binary step dans les implémentations suivantes).

schéma d'un neurone artificiel (src: Stanford)

Nous avons finalement un neurone qui est représenté par les fonctions suivantes :

$$ \sigma(x) = \frac{1}{1 + e^{-x}} $$

$$ \hat{Y} = \sigma(\sum_{i=0}^{|X|-1} {x_i \cdot w_i} + b) $$

Ci-dessous se trouve une implémentation simple de ce que nous avons discuté précédemment. Cette implémentation utilise des paramètres préétablis qui permettent de reproduire des portes logiques OR et AND, afin de vous montrer ce qu'il est possible de faire avec ce modèle très simple (et rudimentaire).

import math
import numpy as np


# sigmoid activation function
sigmoid = lambda x: 1.0 / (1.0 + math.exp(-x)) 
# Bynary step function
bs = lambda x: 1 if x >= 0 else 0


# passing through a neuron
def forward(x, w, b):
    cell_body_sum = np.sum(x * w) + b
    # firing_rate = sigmoid(cell_body_sum)
    firing_rate = bs(cell_body_sum)
    return firing_rate


x_batch = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])

w_or = np.array([1, 1])
b_or = -.5

print("OR logical gate detection")
for x in x_batch:
    y = forward(x, w_or, b_or)
    print(f"[{x[0]}, {x[1]}]: {bool(y)}")

w_and = np.array([1, 1])
b_and = -1.5

print("AND logical gate detection")
for x in x_batch:
    y = forward(x, w_and, b_and)
    print(f"[{x[0]}, {x[1]}]: {bool(y)}")

Notez que vous devez entrer manuellement les valeurs des poids de ce réseau pour qu'il puisse détecter quelque chose. Pour que notre neurone puisse apprendre, il manque deux éléments importants, à savoir :

  • la modification des poids et du biais
  • la détection d'erreur (pour modifier les poids en fonction de celle-ci)

Calcul d'erreur

Il existe plusieurs méthodes pour calculer l'erreur dans un perceptron. La méthode la plus couramment utilisée est l'erreur quadratique moyenne (MSE), qui calcule la moyenne des carrés des différences entre les sorties réelles et prédites pour toutes les entrées d'apprentissage.

$$ MSE = \frac{1}{n} \sum_{i=0}^{n-1} (y_i - \hat{y}_i)^2 $$

Avec n le nombre d'entrées, yi une sortie réelle et yi hat une sortie prédite par le modèle.

Cependant, dans ces implémentations, nous utilisons une autre méthode pour calculer l'erreur appelée erreur en ligne ou erreur de Rosenblatt. Cette méthode calcule l'erreur pour chaque entrée individuelle et met à jour les poids après chaque itération, plutôt qu'après avoir parcouru toutes les entrées d'apprentissage comme c'est le cas pour l'erreur quadratique moyenne.

Plus précisément, l'erreur de Rosenblatt est définie comme suit :

$$ err = y_i - \hat{y}_i $$

Il convient de noter que l'erreur de Rosenblatt n'est pas une méthode très précise pour calculer l'erreur, car elle ne prend pas en compte les erreurs commises sur les autres entrées d'apprentissage. Cependant, elle est souvent utilisée pour sa simplicité et sa rapidité de convergence.

Cette erreur est ensuite utilisée pour mettre à jour les poids du perceptron.

Modification des poids et du biais

Pour notre implémentation, nous allons utiliser la règle de Hebb, qui modifiera les poids et le biais en fonction de l'erreur. C'est une méthode souvent utilisée pour les tâches simples de classification binaire et pour les réseaux de neurones à une seule couche. Elle est définie comme suit :

$$ \Delta W = \eta \cdot X \cdot err $$

donc, nous pouvons déterminer les nouveaux poids de la manière suivante :

$$ W = W + \eta \cdot err \cdot x $$ $$ b = b + \eta \cdot err $$

Vous devriez connaitre toutes les composantes de ces équations sauf le $\eta$, qui correspond au taux d'apprentissage ou learning rate en anglais. C'est un paramètre clé dans les algorithmes d'apprentissage automatique. Il s'agit de la vitesse à laquelle un modèle d'apprentissage automatique ajuste les poids de ses paramètres en réponse à l'erreur de prédiction.

Plus concrètement, lors de l'entraînement, le taux d'apprentissage détermine la quantité par laquelle les poids sont ajustés à chaque itération de la correction des poids. Si le taux d'apprentissage est trop faible, le modèle apprendra lentement, tandis qu'un taux d'apprentissage trop élevé peut ne pas converger vers des poids optimaux et à une performance de prédiction médiocre.

Enfin, avant de pouvoir comprendre parfaitement l'implémentation qui suit, nous devons parler du paramètre "epoch". Le paramètre "epoch" fait référence à une itération complète de l'algorithme d'apprentissage, où l'ensemble de données d'entraînement est vu une fois. Autrement dit, une epoch est une passe complète du jeu de données lors de l'apprentissage d'un modèle d'apprentissage automatique. Lors de l'entraînement d'un modèle, nous spécifions le nombre d'epochs à utiliser. Plus le nombre d'epochs est élevé, plus le modèle a la possibilité d'apprendre des caractéristiques dans les données d'entraînement. Cependant, trop d'epochs peuvent conduire à un surapprentissage, où le modèle s'adapte trop bien aux données d'entraînement et ne généralise pas bien aux nouvelles données.

Maintenant que nous avons vu tous ces éléments, voici l'implémentation finale de notre perceptron qui apprend une porte logique AND :

import numpy as np

class Perceptron:
    def __init__(self, input_size, lr=0.1, epochs=3):
        self.W = np.zeros(input_size)
        self.b = 0
        self.epochs = epochs
        self.lr = lr

    def activation_fn(self, x):
        return 1 if x >= 0 else 0

    def predict(self, x):
        z = self.W.T.dot(x) + self.b
        y_hat = self.activation_fn(z)
        return y_hat

    def fit(self, X, Y):
        for _ in range(self.epochs):
            for i, x in enumerate(X):
                y_hat = self.predict(x)
                e = Y[i] - y_hat
                self.W = self.W + self.lr * e * x
                self.b = self.b + self.lr * e


X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
Y = np.array([0, 0, 0, 1])

p = Perceptron(input_size=2)
p.fit(X, Y)

print(f"X . {p.W} + {p.b}")

print(f"{[0, 0]}: {bool(p.predict(np.array([0, 0])))}")
print(f"{[0, 1]}: {bool(p.predict(np.array([0, 1])))}")
print(f"{[1, 0]}: {bool(p.predict(np.array([1, 0])))}")
print(f"{[1, 1]}: {bool(p.predict(np.array([1, 1])))}")

Je vous laisse voir si cela fonctionne sur une porte logique OU (Y = [0, 1, 1, 1]). Et s'il vous reste de l'énergie, je vous laisse vous casser la tête sur une porte logique XOR...

(SPOILER : une des propriétés du perceptron et qu'il ne peut classifier de facon binaire un ensemble de points seulement s'ils sont linéairement séparables. Or pour un XOR, il faudrait 2 droites/plans pour les séparer ! Il n'est par conséquent pas possible de reproduire le comportement d'une porte XOR avec un perceptron.)