Algorithme d'apprentissage d'un réseau de neurones

Théorie :
Avertissement : Le but de ce chaptire est de donner une intuition sur le fonctionement de la backward pass (appelée retro-propagation du gradient). Il n'est pas nécessaire de comprendre les mécanismes dans leurs détails mais d'avoir une vue globale de l'algorithme.

Descente de gradient

Un réseau est entrainé en deux phases : la forward pass et la backward pass. La forward pass correspond à faire passer les exemples d'entrainement dans le réseau et de caculer la fonction de perte (ou loss) correspondante. La backward pass consiste à mettre à jour les poids du réseau pour minimiser cette fonction loss lors des prochaines forward pass. Cette procédure est répétée jusqu'à convergence, on appelle cela l'entraînement du réseau.

Que veut dire minimiser une fonction ? Reprenons notre cas de classification qui utilise la fonction loss cross-entropy. Elle se définit comme suit : $$\xi(t_i,y_i) = -t_i log(y_i) - (1-t_i)log(1-y_i)$$ où $t_i$ est la prédiction d'un modèle pour un point et $y_i$ la vraie classe du point (appelée aussi ground-truth). Nous travaillons par batch de plusieurs exemples, nous pouvons donc faire la moyenne de la manière suivante : $$\xi(t,y) = - \frac{1}{N} \sum_{i=1}^{n} \left[ t_i log(y_i) + (1-t_i)log(1-y_i) \right]$$ où $N$ est le nombre d'exemples dans le batch.

Prenons ensuite le cas d'une classification binaire, linéairement séparable, avec des points $x \in \mathbb{R}^2$, ainsi il nous faut uniquement un NN composé d'un perceptron avec un poids $w = \{w_1, w_2\}$. Nous allons prendre une étendue de 100 valeurs pour ces deux poids entre $-10$ et $5$, et pour chaque combinaison de poids $\(w_1, w_2\)$ (au nombre de $100^2$), calculer la loss pour ce problème.

Définissons premièrement le réseau et la loss:

In [*]:
from sklearn.datasets import make_blobs
import numpy as np  # Matrix and vector computation package
import matplotlib.pyplot as plt  # Plotting library
from matplotlib import cm # Colormaps
from mpl_toolkits.mplot3d import Axes3D  # 3D plots
import seaborn as sns  # Fancier plots


X, y = make_blobs(n_samples=40, centers=2, random_state=6, center_box=(-1.0, 1.0), cluster_std=0.6)
y = y.reshape(-1,1)

# Define the loss function
def loss(y, t):
    return - np.mean(
        np.multiply(t, np.log(y)) + np.multiply((1-t), np.log(1-y)))

def logistic(zo):
    return 1. / (1. + np.exp(-zo))

def nn(x, w):
    return logistic(x.dot(w.T))

On calcule la loss pour chaque combinaison de poids :

In [*]:
grid_size = 100
w1 = np.linspace(-10, 5, num=grid_size) #  100 points entre 5 et -5 pour poids b
w2 = np.linspace(-10, 5, num=grid_size) #  100 points entre 5 et -5 pour poids b
params_x, params_y = np.meshgrid(w1, w2) # generate grid
loss_grid = np.zeros((grid_size, grid_size))

# On calcul la loss pour chaque combinaison des 2 poids
for i in range(grid_size):
    for j in range(grid_size):
        loss_grid[i,j] = loss(
            nn(X, np.asmatrix([params_x[i,j], params_y[i,j]])) , y)

Puis créons le graphe de cette loss :

In [*]:
# Plot the loss function surface
fig = plt.figure(figsize=(6, 4))
with sns.axes_style("whitegrid"):
    ax = Axes3D(fig)
# plot the surface
surf = ax.plot_surface(
    params_x, params_y, loss_grid, linewidth=0, cmap=cm.magma)
ax.view_init(elev=60, azim=-30)
cbar = fig.colorbar(surf)
ax.set_xlabel('$w_1$', fontsize=12)
ax.set_ylabel('$w_2$', fontsize=12)
ax.set_zlabel('$\\xi$', fontsize=12)
ax.set_ylim(-10, 5)
ax.set_xlim(-10, 5)
cbar.ax.set_ylabel('$\\xi$', fontsize=12, rotation=0)
plt.title('Loss function surface')
plt.show()
Nous voyons que la courbure du réseau est comme un grand bol (en l'occurence convexe) avec un minimum global où la loss vaudrait environ 0.6.
Attention : Nous avons dit dans la section précédente que les NN souffraient de plusieurs minima locaux : pourquoi alors la courbure du réseau est convexe, càd qu'elle n'admet qu'un minimum global ? Il faut retenir que le problème de minima locaux apparait lorsqu'on utilise une ou plusieurs couches cachées. En effet, voici la transformation de la courbure après l'ajout d'une couche supplémentaire :
Nous voyons l'apparition de deux minima locaux, il est difficile lors de la minimisation de la loss de savoir dans quel creux se trouve la loss la plus basse.
Un algorithme d'optimisation permettant de minimiser une fonction est la descente de gradient stochastique (Stochastic gradient descent, SGD) comme vu dans les précédentes sections. La SGD fonctionne en prenant le gradient (ou la dérivée) de la fonction loss $\xi$ en fonction des paramètres du réseau.
Définition : La dérivée mesure l'ampleur du changement d'une fonction de sortie (ici, notre loss) par rapport à un changement de son entrée (ici, nos poids). Le gradient est une généralisation à plusieurs variables (comme un NN) de la dérivée qui ne traite que les fonctions d'une variable.

Le gradient permet de trouver comment chaque poids a contribué à la loss. Il est donc possible de minimiser la fonction loss en mettant à jour chaque poids suivant la négative du gradient de manière itérative : c'est la backward pass.


Plus formellement, à chaque itération $k$, le poids $w$ est mis à jour en suivant : $$\mathbf{w}(k+1) = \mathbf{w}(k) - \Delta \mathbf{w}(k+1)$$ où le gradient $\Delta \mathbf{w} = \mu \frac{\partial \xi}{\partial \mathbf{w}}$ et $\mu$ est le pas d'apprentissage (appelé aussi learning rate). Le pas d'apprentissage définit donc l'importance avec laquelle le changement de direction est appliqué au poids.


Pour trouver ${\partial \xi_i}/{\partial \mathbf{w}}$ de l'exemple $i$, il suffit de remonter la chaîne de la forward pass pour connaître comment $w$ a contribué à ${ \xi_i}$

. Si on considère que la prédiction $y_i$ était simplement engendrée par $w$, on pourrait écrire la chaîne : $$\frac{\partial \xi_i}{\partial w} = \frac{\partial \xi_i}{\partial y_i} \frac{\partial y_i}{\partial w}$$ Mais nous avons utilisé la fonction sigmoïde pour la prédiction. En effet, si l'on devait décrire notre système en entier, on définirait $y_i = \sigma(z_i)$, la sortie de la fonction logisitique et $z_i = \mathbf{x}_i \cdot \mathbf{w}^T$, l'entrée de la fonction logistique. On réécrit alors la chaîne : $$\frac{\partial \xi_i}{\partial \mathbf{w}} = \frac{\partial \xi_i}{\partial y_i} \frac{\partial y_i}{\partial z_i} \frac{\partial z_i}{\partial \mathbf{w}}$$ Nous n'allons pas effectuer la dérivée car ce n'est pas le but de ce cours. Le gradient est obtenu par l'expression suivante : $$\Delta \mathbf{w} = \mu \cdot \frac{1}{N} \sum_{i=1}^{N} \mathbf{x}_i (y_i - t_i)$$ Nous pouvons ainsi implémenter cette fonction et réaliser la descente stochastique de gradient :
In [*]:
def gradient(w, x, t):
    return (nn(x, w) - t).T * x

# On initilalise la matrice de manière aléatoire
w = np.asmatrix([-4,4])
# pas d'apprentissage
learning_rate = 0.05

nb_of_iterations = 10  # On choisit dix descentes de gradient
w_iter = []
l_iter = []
for i in range(nb_of_iterations):
    w_iter.append(w)  # on garde les poids pour le plot 
    l_iter.append(loss(nn(X, w),y))  # on garde la loss pour le plot 
    dw = gradient(w, X, y)  # calcul du gradient
    w = w - learning_rate * dw  # maj du poids

for l,m in zip(l_iter, w_iter):
    print("Loss:",l,"w:",m)
Loss: 3.397738202762406 w: [[-4  4]]
Loss: 2.7258153066468482 w: [[-3.25650149  3.10643653]]
Loss: 2.074322581668281 w: [[-2.52141824  2.22454942]]
Loss: 1.4676770441773184 w: [[-1.80888838  1.36361784]]
Loss: 0.9710496721769445 w: [[-1.15588633  0.55317126]]
Loss: 0.6985049062554499 w: [[-0.65766785 -0.11197573]]
Loss: 0.6291438634859079 w: [[-0.43803598 -0.49853019]]
Loss: 0.6150189294765884 w: [[-0.41147491 -0.68685891]]
Loss: 0.6090283230628221 w: [[-0.44173631 -0.79981828]]
Loss: 0.6053519207544842 w: [[-0.48253108 -0.88018438]]

La loss est bien de 0.6 comme estimée sur le graphe. On peut maintenant dessiner les mises à jour des poids :

In [*]:
# Plot the error surface
fig = plt.figure(figsize=(6, 4))
with sns.axes_style("whitegrid"):
    ax = Axes3D(fig)
surf = ax.plot_surface(
    params_x, params_y, loss_grid, linewidth=0, cmap=cm.magma)
ax.view_init(elev=60, azim=-30)
cbar = fig.colorbar(surf)
cbar.ax.set_ylabel('$\\xi$', fontsize=12, rotation=0)

# Plot the updates
for i in range(1, 10):
    w1 = w_iter[i - 1]
    w2 = w_iter[i]

    l1 = l_iter[i - 1]
    l2 = l_iter[i]

    ax.plot([w1[0,0], w2[0,0]], [w1[0,1], w2[0,1]], [l1, l2], 'w-')
# Show figure
ax.set_xlabel('$w_1$', fontsize=12)
ax.set_ylabel('$w_2$', fontsize=12)
ax.set_zlabel('$\\xi$', fontsize=12)
ax.set_ylim(-10, 5)
ax.set_xlim(-10, 5)
plt.title('Gradient descent updates on loss surface')
plt.show()

Comme nous l'avons vu dans les sections précédentes, les librairies python deep-learning possèdent des outils de différentiation automatique et mettent à jour les poids automatiquement après chaque forward pass (selon le pas d'apprentissage que vous leur renseignez).