L'apprentissage dans un réseau de neurones désigne le processus automatisé permettant d'ajuster les paramètres de poids optimaux à partir d'un jeu de données d'entraînement. L'objectif principal est de minimiser une fonction de perte, qui mesure l'écart entre les prédictions du modèle et la réalité.
L'apprentissage basé sur les données
Contrairement aux approches de programmation classique, l'apprentissage automatique (Machine Learning) cherche à extraire des motifs directement des données. En vision par ordinateur, on extrayait autrefois manuellement des vecteurs de caractéristiques (features) avant de les fournir à un algorithme. Le Deep Learning se distingue par une approche "bout en bout" (end-to-end), où le réseau apprend lui-même les caractéristiques pertinentes directement à partir des pixels bruts.
Pour évaluer la performance d'un modèle, on divise les données en deux ensembles :
- Données d'entraînement : utilisées pour ajuster les poids du modèle.
- Données de test : utilisées pour évaluer la capacité de généralisation sur des données inconnues.
Un modèle qui réussit parfaitement sur l'entraînement mais échoue sur le test est en état de surapprentissage (overfitting).
Fonctions de perte
La fonction de perte quantifie l'imprécision du réseau. Elle sert de boussole pour orienter l'ajustement des paramètres.
Erreur Quadratique Moyenne (MSE)
Souvent utilisée pour les problèmes de régression, elle se calcule ainsi :
\[ E = \frac{1}{2}\sum_k(y_k - t_k)^2 \]
import numpy as np
def compute_mse(prediction, target):
return 0.5 * np.sum((prediction - target) ** 2)
Entropie croisée (Cross-Entropy)
Idéale pour la classification, elle pénalise fortement les prédictions erronées avec une certitude élevée :
\[ E = -\sum_k{t_k\ \log y_k} \]
def compute_cross_entropy(y_pred, y_true):
epsilon = 1e-8
return -np.sum(y_true * np.log(y_pred + epsilon))
Apprentissage par mini-batch
Calculer la perte sur l'intégralité d'un dataset massif est coûteux en ressources. On utilise donc des mini-batchs : un sous-ensemble aléatoire de données qui sert d'approximation pour calculer le gradient et mettre à jour les poids.
def batch_cross_entropy(preds, labels):
if preds.ndim == 1:
labels = labels.reshape(1, labels.size)
preds = preds.reshape(1, preds.size)
batch_count = preds.shape[0]
# Gestion des étiquettes au format index ou one-hot
if labels.size == preds.size:
return -np.sum(labels * np.log(preds + 1e-7)) / batch_count
else:
return -np.sum(np.log(preds[np.arange(batch_count), labels] + 1e-7)) / batch_count
Calcul du gradient et descente de gradient
Le gradient représente la direction dans laquelle la fonction de perte augmente le plus rapidement. Pour minimiser la perte, nous nous déplaçons dans la direction opposée au gradient.
Différenciation numérique
Pour calculer la dérivée d'une fonction numériquement, on utilise la méthode de la différence centrale pour plus de précision :
def estimate_gradient(func, params):
delta = 1e-4
grad = np.zeros_like(params)
it = np.nditer(params, flags=['multi_index'], op_flags=['readwrite'])
while not it.finished:
idx = it.multi_index
original_val = params[idx]
params[idx] = original_val + delta
upper_val = func(params)
params[idx] = original_val - delta
lower_val = func(params)
grad[idx] = (upper_val - lower_val) / (2 * delta)
params[idx] = original_val
it.iternext()
return grad
Optimisation par descente de gradient
La mise à jour des paramètres se fait selon la formule suivante, où \(\eta\) est le taux d'apprentissage (learning rate) :
\[ W = W - \eta \frac{\partial L}{\partial W} \]
def optimize_gradient(f, initial_params, lr=0.01, epochs=100):
p = initial_params
for _ in range(epochs):
g = estimate_gradient(f, p)
p -= lr * g
return p
Implémentation d'un réseau à deux couches
Voici une structure simplifiée d'un réseau de neurones complet intégrant ces concepts.
from common.functions import sigmoid, softmax
class SimpleNeuralNet:
def __init__(self, input_dim, hidden_dim, output_dim, weight_scale=0.01):
self.params = {
'W1': weight_scale * np.random.randn(input_dim, hidden_dim),
'b1': np.zeros(hidden_dim),
'W2': weight_scale * np.random.randn(hidden_dim, output_dim),
'b2': np.zeros(output_dim)
}
def forward(self, x):
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
layer1 = sigmoid(np.dot(x, W1) + b1)
out = softmax(np.dot(layer1, W2) + b2)
return out
def get_loss(self, x, t):
y = self.forward(x)
return batch_cross_entropy(y, t)
def calculate_grads(self, x, t):
loss_wrapper = lambda W: self.get_loss(x, t)
grads = {
'W1': estimate_gradient(loss_wrapper, self.params['W1']),
'b1': estimate_gradient(loss_wrapper, self.params['b1']),
'W2': estimate_gradient(loss_wrapper, self.params['W2']),
'b2': estimate_gradient(loss_wrapper, self.params['b2'])
}
return grads
Le cycle d'apprantissage suit une boucle répétitive : sélection d'un mini-batch, calcul du gradient de la perte par rapport aux paramètres, et mise à jour des poids via la descente de gradient. Ce processus est répété sur plusieurs époques jusqu'à ce que la performance sur les données de test soit jugée satifsaisante.