Les réseaux neuronaux articifiels sont basés sur notre compréhension du cerveau: un neurone est un corps cellullaire relié à d’autres corps cellulaires via des nerfs. Chaque corps cellulaire reçoit des signaux électriques de différentes forces via les dendrites (entrées), traite ces signaux, puis envoie un signal électrique via l’axone (sortie) — à dire vrai, le neurone n’envoie de signal que s’il est activé, ce qui se produit s’il y a un nombre suffisant de signaux en entrées. Tous les neurones reliés au neurone vont alors recevoir le signal, le traiter, et ainsi de suite.
À un niveau simple, on peut considérer qu’un neurone est une unité de calcul, qui prend différentes valeurs en entrée, traite ces valeurs, et retourne une sortie.
Dans le cortex, les neurones semblent être disposés en piles (colonnes corticales), qui traitent l’information en parallèle. Chacune de ces “hyper-colonnes” est consituée de “mini-colonnes” d’environ 100 neurones. Il y a environ 100 millions de ces mini-colonnes dans le cortex.
Cette architecture est similaire à celle d’une carte graphique (GPU) — elle est constituée d’un ensemble d’unité de traitement, responsables de calculer un ensemble de pixels sur l’écran. Ces mêmes cartes graphiques, utilisée pour jouer aux jeux vidéos, peuvent être utilisées pour exécuter des réseaux neuronaux artificiels — et ce beaucoup plus rapidement qu’avec un CPU.
Dans un réseau neuronal artificiel, on parle de neurone ou d’unité.
Ces unités sont représentées sous forme de noeuds sur un graphique.
En 1943, McCulloch & Pitts proposent le premier modèle mathématique du neurone artificiel:
Les entrées sont booléennes et la sortie est également booléenne. On peut donc considérer que le neurone MCP est juste une fonction booléenne.
Pour des opérations booléennes plus complexes, on peut assembler différents neurones les uns à la suite des autres.
McCulloch-Pitts Neuron — Mankind’s First Mathematical Model Of A Biological Neuron
Some specific models of artificial neural nets
Un LTU s’appuie sur la même idée que le neurone MCP mais attribue des poids aux différentes entrées plutôt que de désigner des entrées exitatoires et inhibitoires
\[f(x) = \begin{cases} 1 & \text{si } w \cdot x + b > 0 \\ 0 & \text{sinon} \end{cases}\]L’entrée x0 vaudra toujours -1, ce qui nous permet de définir le seuil en même temps que le reste des poids (w0 sera le seuil).
\[\begin{aligned} w_0 x_0 + w_1 x_1 + \cdots + w_n x_n &= 0 \\ -w_0 + w_1 x_1 + \cdots + w_n x_n &= 0 \\ w_1 x_1 + \cdots + w_n x_n &= w_0 \end{aligned}\]Les entrées ne sont plus limitées à des valeurs booléennes mais acceptent des valeurs réelles. Les poids (y compris le seuil) peuvent également être des valeurs réelles.
En 1958, Frank Rosenblatt invente le Perceptron:
il démontre qu’on peut modifier le neurone MCP en LTU — c’est à dire utiliser des valeurs réelles et non plus binaires en entrée.
il met au moint un algorithme simple pour apprendre les poids corrects (y compris le seuil) à partir de données d’entraînement. Notons qu’ici on calcule le bias indépendemment, mais on aurait pu ajouter une caractéristique -1 à X.
Le perceptron reste limité aux problèmes linéairement séparables: une donnée mal classifiée empêche le modèle de converger.
En 1959, Ted Hoff introduit Adaline: pour mettre à jour les poids, on va quantifier l’erreur avec la somme des erreurs au carré
\[\text{Fonction coût} \\ L(w,b) = \frac{1}{2} \sum_{i=1}^n (y_i - \phi(w^T x_i + b))^2\]Et puisqu’on cherche à minimiser cette erreur, met à jour les poids proportionnellement à la derivée de la fonction coût
\[\text{Dérivée fonction coût} \\ \begin{aligned} \frac{\partial L}{\partial w} &= \sum_{i=1}^n (w^T x_i + b - y_i) x_i \\ \frac{\partial L}{\partial b} &= \sum_{i=1}^n w^T x_i + b - y_i \end{aligned}\] \[\text{Mise à jour des poids} \\ w = w - \alpha \frac{\partial L}{\partial w} \\ b = b - \alpha \frac{\partial L}{\partial b}\]… il s’agit donc du gradient descent — déjà vu dans la partie Régression Linéaire.
On a désormais l’architecture d’un neurone telle qu’elle existe aujourd’hui:
Des entrées
Une fonction qui effectue la somme des entrées pondérées par leur poids (∑p = w ⋅ x + b)
Une fonction d’activation, qui convertit la somme pondérée en sortie.
La fonction d’activation utilisée jusqu’à présent est appelée heaviside step function (0 si ∑p <= 0, 1 sinon)
Une fonction coût, dont on calculera la dérivée pour minimiser l’erreur.
La fonction coût utilisée dans Adaline est l’erreur carrée (∑(y̅ - y)²)
Un optimiseur, algorithme utilisé pour minimiser la fonction coût.
L’optimiseur utilisé jusqu’à présent est gradient descent.
Ne reste plus qu’un pas pour créer un réseau neuronal: relier plusieurs neurones entre eux.
Les entrées vont être données à différents neurones et les sorties de ces neurones vont devenir les entrées d’autres neurones.
La première couche (layer en anglais) du réseau neuronal est l’ensemble des entrées: input layer.
La dernière couche est l’ensemble des sorties: output layer.
Entre les deux, les couches sont dites cachées, puisqu’on ne voit jamais leurs sorties directement: hidden layers.
Quand l’ensemble des neurones d’une couche prennent les mêmes entrées (c’est à dire quand toutes les sorties de la couche précédente sont prises comme entrées pour chaque neurone) alors la couche est dite dense — c’est le cas pour l’ensemble des couches dans l’image ci-dessus.
La fonction d’activation des neurones est non linéaire, et c’est ce qui crée la complexité du réseau neuronal.
\[\text{Equation avec fonction d'activation} \\ Y = f(W3 \cdot f(W2 \cdot f(W1 \cdot X + B1) + B2) + B3) \\[10pt] \text{Equation sans fonction d'activation} \\ \begin{aligned} Y &= W3 \cdot W2 \cdot W1 \cdot X + B1 + B2 + B3 \\ &= W \cdot X + B \end{aligned}\]Un réseau neuronal ayant qu’une couche cachée est dit superficiel (shallow en anglais), tandis un réseau neuronal ayant deux couches cachées ou plus est dit profond (deep en anglais).
Les réseaux neuronaux profonds vont pouvoir apprendre des règles complexes pour résoudre des problèmes complexes. On parle d’apprentissage profond (deep learning en anglais).
Comment apprendre les poids des différentes liaisons?
Première étape, les initialiser aléatoirement. Les liaisons doivent avoir des poids différents, autrement tous les neurones font tous la même chose et ajustent leurs poids de la même manière — ils n’apprennent pas.
Deuxième étape, mettre à jour les poids intelligemment pour minimiser les erreurs.
On utilise la règle de dérivation en chaîne (chain rule) en partant de la droite — de la couche en sortie vers la couche en entrée:
a0 est faux parce que le passage de ah11 à a0 est faux.
On calcule de combien w20 contribue à l’erreur et on met à jour sa valeur proportionnellement.
w21 contribue également à l’erreur.
On va donc mettre à jour sa valeur sur le même principe.
Avant ça, ah11 était faux parce que le passage de ah21 à ah11 était faux.
On va ajuster les poids en prenant en compte les erreurs provenant de toutes les directions possibles pour le neurone.
Parcourir le réseau neuronal de gauche à droite (entrées → sorties), c’est à dire calculer la résultat de chacune des fonctions d’activation, est appelé forward propagation.
Parcourir le réseau neuronal de droite à gauche (sorties → entrées), c’est à dire ajuster les poids de chacune des liaisons, est appelé backward propagation.
Lors de l’initialisation, les poids doivent
Il existe différentes manières d’initialiser les poids:
Distribution uniforme
Les poids sont initialisés avec une distribution uniforme, entre -1/√n_in et 1/√n_in — où n_in est le nombre d’entrées.
Xavier/Gorat
Quand on utilise sigmoid pour fonction d’activation
Si les données en entrée suivent une distribution normale:
Les poids sont initialisés avec une distribution normale, avec une moyenne de 0 et un écart-type de √(2/(n_in+n_out)) — où n_in est le nombre d’entrées et n_out le nombre de sorties.
Si les données en entrée suivent une distribution uniforme:
Les poids sont initialisés avec une distribution uniforme, entre -√6/√(n_in + n_out) et +…
He init
Quand on utilise relu pour fonction d’activation
Si les données en entrée suivent une distribution normale:
Les poids sont initialisés avec une distribution normale, avec une moyenne de 0 et un écart-type de √(2/n_in)
Si les données en entrée suivent une distribution uniforme:
Les poids sont initialisés avec une distribution uniforme, entre -√6/√n_in et +…
En utilisant un framework, tel que Keras, il suffit d’indiquer l’architecture du réseau neuronal qu’on veut:
from tensorflow import keras
from tensorflow.keras import layers
# Define architecture
model = keras.Sequential([
layers.Dense(units=4, activation='relu', input_shape=[2]),
layers.Dense(units=3, activation='relu'),
layers.Dense(units=1),
])
# Define optimizer
model.compile(
optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy']
)
# Train
r = model.fit(
X_train, y_train,
validation_data=(X_test, y_test),
batch_size=256,
epochs=100
)
# Check convergence
df_history = pd.DataFrame(r.history)
df_history.loc[:, ['loss', 'val_loss']].plot()
NB une manière habituelle de définir chaque couche est d’utiliser des paramètres comme activation="relu"
mais on peut séparer les différentes parties dans leur propre couche:
model = keras.models.Sequential([
layers.Input(shape=(D,)),
layers.Dense(1)
layers.Activation('relu')
])
Des années 1980 à 1995, on utilisait la fonction sigmoid comme fonction d’activation pour toutes les couches.
Or la dérivée de sigmoid est très petite (valeur max: 0.25). Conséquence: lors de la backpropagation, lorsqu’on calcule la dérivée via dérivation en chaîne, on multiplie des valeurs très petites par des valeurs très petites: au final les dérivées deviennent si petites qu’on ne met plus à jour les poids. C’est un problème de vanishing gradient (dérivées qui disparaissent en français). Pour l’éviter: utiliser relu ou leaky relu pour les couches cachées.
Inversemment, si les dérivées sont grandes, on multiplie de grandes valeurs avec de grande valeurs: au final les dérivées deviennent si grandes qu’il devient impossible de les utiliser (valeur inf ou NaN). C’est un problème d’exploding gradient (dérivées qui explosent en français). Pour l’éviter: normaliser les données.
Exploding gradients are a problem where large error gradients accumulate and result in very large up dates to neural network model weights during training. At an extreme, the values of weights can become so large as to overflow and result in NaN values. This has the effect of your model being unstable and unable to learn from your training data.
Décider de l’architecture ne va pas de soi, mais est le résultat d’un processus: on commence simplement, on évalue les résultats, on modifie l’architecture et on vérifie si on va dans le bon sens. On continue d’expérimenter jusqu’à atteindre une performance jugée acceptable.
La capacité d’un modèle fait référence à la taille et complexité des motifs que le modèle est capable d’apprendre. Pour un réseau neuronal, la capacité du modèle est largement déterminée par le nombre de neurones qu’il possède et la façon dont ils sont connectés entre eux.
On commence généralement par un modèle relativement simple et, si le réseau neuronal ne semble pas parvenir à apprendre les données (underfitting), on augmente sa capacité
soit en l’élargissant — en ajoutant des unités aux couches existantes
Les réseaux plus larges ont plus de facilité à apprendre des relations linéaires
soit en l’approfondissant — en ajoutant des couches supplémentes
Les réseaux plus profonds ont plus de facilité à apprendre des relations non linéaires.
La meilleure solution dépend simplement de l’ensemble de données.
Si on commence généralement par normaliser les données avant d’entraîner le modèle, au sein du réseau neuronal, le résultat de la fonction d’activation n’aura plus la même distribution: les données deviennent sont de moins en moins normalisées pour les couches qui suivent.
On peut ajouter une couche de normalisation, dit batch normalization, pour normaliser les données batch par batch entre deux couches.
Les modèles avec batchnorm ont tendance à nécessiter moins d’epochs pour converger. Ça peut également aider lorsque le modèle ne parvient plus à apprendre (underfit).
# Après activation
layers.Dense(16, activation='relu'),
layers.BatchNormalization(),
# Ou après somme pondérée
layers.Dense(16),
layers.BatchNormalization(),
layers.Activation('relu'),
On peut détecter lorsque le modèle se met à apprendre du bruit et non plus du signal (overfitting): le coût continue de diminuer sur les données d’entraînement et non sur les données de validation. Pour éviter que le modèle soit sur-ajusté, on peut simplement arrêter de l’entraîner dès que ça se produit.
On peut par exemple définir un nombre d’epochs inutilement large et, s’il n’y a pas eu une améloration d’au moins 0.001 sur les données de validation sur les 20 dernières epochs, stopper l’entraînement. C’est ce qu’on appelle un arrêt anticipé (early stopping en anglais)
from tensorflow.keras.callbacks import EarlyStopping
early_stopping = EarlyStopping(
min_delta=0.001, # minimium amount of change to count as an improvement
patience=20, # how many epochs to wait before stopping
restore_best_weights=True,
)
history = model.fit(
X_train, y_train,
validation_data=(X_valid, y_valid),
batch_size=256,
epochs=500,
callbacks=[early_stopping], # put your callbacks in a list
verbose=0, # turn off training log
)
Une autre manière d’éviter que le modèle ne deviennent sur-ajusté (overfit) est d’ajouter une couche de dropout, une couche qui ne contient pas de neurones à proprement parler mais ajoute une fonctionnalité supplémentaire: nullifier aléatoirement certains poids. Ça force le modèle a ne pas trop s’appuyer sur certains poids plutôt que d’autres. Le modèle qui en résulte a tendance à apprendre plus uniformément, à ne pas donner trop d’importance à des variations aléatoires.
Si le modèle semble très sur-ajusté, on peut ajouter un dropout important (ex 0.7/0.8), sinon réduire le seuil (ex 0.2). Un dropout de 0.5 désactive aléatoirement 50% des neurones de la couche précédente.
model = keras.Sequential([
layers.Dense(1024, activation='relu', input_shape=[11]),
layers.Dropout(0.3), # apply 30% dropout to the next layer
layers.BatchNormalization(),
layers.Dense(1024, activation='relu'),
layers.Dropout(0.3),
layers.BatchNormalization(),
layers.Dense(1024, activation='relu'),
layers.Dropout(0.3),
layers.BatchNormalization(),
layers.Dense(1),
])
Il s’agit d’une technique qui consiste à utiliser un modèle entraîné sur un autre ensemble de données comme point de départ pour l’entraînement — plutôt que de recommencer à apprendre les poids à partir de rien.
L’apprentissage par transfert (transfer learning en anglais) permet d’accélérer l’entraînement et l’optimisation du modèle. On obtient généralement de meilleures performances avec cette approche.
Il existe des bibliothèques de modèle pré-entraînés pour divers problèmes, disponibles en ligne, appelés zoos de modèles (models zoos).