Optimisation#
Dans ce chapitre, nous présenterons des variantes de la stratégie d’optimisation de descente de gradient et montrerons comment elles peuvent être utilisées pour optimiser les paramètres des réseaux de neurones.
Commençons par l’algorithme de base de la descente de gradient et ses limites.
Algorithm 1 (Descente de Gradient)
Entrée: Un jeu de données \(\mathcal{D} = (X, y)\)
Initialiser les paramètres \(\theta\) du modèle
for \(e = 1 .. E\)
for \((x_i, y_i) \in \mathcal{D}\)
Calculer la prédiction \(\hat{y}_i = m_\theta(x_i)\)
Calculer le gradient individuel \(\nabla_\theta \mathcal{L}_i\)
Calculer le gradient total \(\nabla_\theta \mathcal{L} = \frac{1}{n} \sum_i \nabla_\theta \mathcal{L}_i\)
Mettre à jour les paramètres \(\theta\) à partir de \(\nabla_\theta \mathcal{L}\)
La règle de mise à jour typique pour les paramètres \(\theta\) à l’itération \(t\) est
où \(\rho\) est un hyper-paramètre important de la méthode, appelé le taux d’apprentissage (ou learning rate). La descente de gradient consiste à jour itérativement \(\theta\) dans la direction de la plus forte diminution de la perte \(\mathcal{L}\).
Comme on peut le voir dans l’algorithme précédent, lors d’un descente de gradient, les paramètres du modèle sont mis à jour une fois par epoch, ce qui signifie qu’un passage complet sur l’ensemble des données est nécessaire avant la mise à jour. Lorsque l’on traite de grands jeux de données, cela constitue une forte limitation, ce qui motive l’utilisation de variantes stochastiques.
Descente de gradient stochastique#
L’idée derrière l’algorithme de descente de gradient stochastique (ou Stochastic Gradient Descent, SGD) est d’obtenir des estimations bon marché (au sens de la quantité de calculs nécessaires) pour la quantité
où \(\mathcal{D}\) est l’ensemble d’apprentissage. Pour ce faire, on tire des sous-ensembles de données, appelés minibatchs, et
est utilisé comme estimateur de \(\nabla_\theta \mathcal{L}(\mathcal{D} ; m_\theta)\). Il en résulte l’algorithme suivant dans lequel les mises à jour des paramètres se produisent après chaque minibatch, c’est-à-dire plusieurs fois par epoch.
Algorithm 2 (Descente de gradient stochastique)
Input: A dataset \(\mathcal{D} = (X, y)\)
Initialiser les paramètres \(\theta\) du modèle
for \(e = 1 .. E\)
for \(t = 1 .. n_\text{minibatches}\)
Tirer un échantillon aléatoire de taillle \(b\) dans \(\mathcal{D}\) que l’on appelle minibatch
for \((x_i, y_i) \in \mathcal{B}\)
Calculer la prédiction \(\hat{y}_i = m_\theta(x_i)\)
Calculer le gradient individuel \(\nabla_\theta \mathcal{L}_i\)
Calculer le gradient sommé sur le minibatch \(\nabla_\theta \mathcal{L}_{\mathcal{B}} = \frac{1}{b} \sum_i \nabla_\theta \mathcal{L}_i\)
Mettre à jour les paramètres \(\theta\) à partir de \(\nabla_\theta \mathcal{L}_{\mathcal{B}}\)
Par conséquent, lors de l’utilisation de SGD, les mises à jour des paramètres sont plus fréquentes, mais elles sont « bruitées » puisqu’elles sont basées sur une estimation du gradient par minibatch au lieu de s’appuyer sur le vrai gradient, comme illustré ci-dessous :
Outre le fait qu’elle implique des mises à jour plus fréquentes des paramètres, la SGD présente un avantage supplémentaire en termes d’optimisation, qui est essentiel pour les réseaux de neurones. En effet, comme on peut le voir ci-dessous, contrairement à ce que nous avions dans le cas du Perceptron, la perte MSE (et il en va de même pour la perte logistique) n’est plus convexe en les paramètres du modèle dès que celui-ci possède au moins une couche cachée :
La descente de gradient est connue pour souffrir d’optima locaux, et de tels fonctions de pertes constituent un problème sérieux pour la descente de gradient. D’un autre côté, la descente de gradient stochastique est susceptible de bénéficier d’estimations de gradient bruitées pour s’échapper des minima locaux.
Une note sur Adam#
Adam [Kingma and Ba, 2015] est une variante de la méthode de descente de gradient stochastique. Elle diffère dans la règle de mise à jour des paramètres.
Tout d’abord, elle utilise ce qu’on appelle le momentum, qui consiste essentiellement à s’appuyer sur les mises à jour antérieures du gradient pour lisser la trajectoire dans l’espace des paramètres pendant l’optimisation. Une illustration interactive du momentum peut être trouvée dans [Goh, 2017].
L’estimation du gradient est remplacée par la quantité :
Lorsque \(\beta_1\) est égal à zéro, nous avons \(\mathbf{m}^{(t+1)} = \nabla_\theta \mathcal{L}\) et pour \(\beta_1 \in ]0, 1[\), \(\mathbf{m}^{(t+1)}\) l’estimation courante du gradient utilise l’information sur les estimations passées, stockée dans \(\mathbf{m}^{(t)}\).
Une autre différence importante entre SGD et la Adam consiste à utiliser un taux d’apprentissage adaptatif. En d’autres termes, au lieu d’utiliser le même taux d’apprentissage \(\rho\) pour tous les paramètres du modèle, le taux d’apprentissage pour un paramètre donné \(\theta_i\) est défini comme :
où \(\epsilon\) est une constante petite devant 1 et
Ici aussi, le terme \(s\) utilise le momentum. Par conséquent, le taux d’apprentissage sera réduit pour les paramètres qui ont subi de grandes mises à jour dans les itérations précédentes.
Globalement, la règle de mise à jour d’Adam est la suivante :
La malédiction de la profondeur#
Considérons le réseau neuronal suivant :
et rappelons que, pour une couche donnée \((\ell)\), la sortie de la couche est calculée comme suit
où \(\varphi\) est la fonction d’activation pour la couche donnée (nous ignorons les termes de biais dans cet exemple simplifié).
Afin d’effectuer une descente de gradient (stochastique), les gradients de la perte par rapport aux paramètres du modèle doivent être calculés.
En utilisant la règle de la dérivation en chaîne, ces gradients peuvent être exprimés comme suit :
Il y a des idées importantes à saisir ici.
Tout d’abord, il faut remarquer que les poids qui sont plus éloignés de la sortie du modèle héritent de règles de gradient composées de plus de termes. Par conséquent, lorsque certains de ces termes deviennent de plus en plus petits, il y a un risque plus élevé pour ces poids que leurs gradients tombent à 0. C’est ce qu’on appelle l’effet de gradient évanescent (vanishing gradient), qui est un phénomène très courant dans les réseaux neuronaux profonds (c’est-à-dire les réseaux composés de nombreuses couches).
Deuxièmement, certains termes sont répétés dans ces formules, et en général, des termes de la forme \(\frac{\partial a^{(\ell)}}{\partial o^{(\ell)}}\) et \(\frac{\partial o^{(\ell)}}{\partial a^{(\ell-1)}}\) sont présents à plusieurs endroits. Ces termes peuvent être développés comme suit :
Voyons à quoi ressemblent les dérivées des fonctions d’activation standard :
On peut constater que la dérivée de ReLU possède une plus grande plage de valeurs d’entrée pour lesquelles elle est non nulle (typiquement toute la plage de valeurs d’entrée positives) que ses concurrentes, ce qui en fait une fonction d’activation très intéressante pour les réseaux neuronaux profonds, car nous avons vu que le terme \(\frac{\partial a^{(\ell)}}{\partial o^{(\ell)}}\) apparaît de manière répétée dans les dérivations en chaîne.
Coder tout cela en keras#
Dans keras, les informations sur les pertes et l’optimiseur sont transmises au moment de la compilation :
import keras
from keras.layers import Dense, InputLayer
from keras.models import Sequential
model = Sequential([
InputLayer(input_shape=(10, )),
Dense(units=20, activation="relu"),
Dense(units=3, activation="softmax")
])
model.summary()
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ dense (Dense) │ (None, 20) │ 220 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 3) │ 63 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 283 (1.11 KB)
Trainable params: 283 (1.11 KB)
Non-trainable params: 0 (0.00 B)
model.compile(loss="categorical_crossentropy", optimizer="adam")
En termes de pertes :
"mse"est la perte d’erreur quadratique moyenne,"binary_crossentropy"est la perte logistique pour la classification binaire,"categorical_crossentropy"est la perte logistique pour la classification multi-classes.
Les optimiseurs définis dans cette section sont disponibles sous forme de "sgd" et "adam".
Afin d’avoir le contrôle sur les hyper-paramètres des optimiseurs, on peut alternativement utiliser la syntaxe suivante :
from keras.optimizers import Adam, SGD
# Not a very good idea to tune beta_1
# and beta_2 parameters in Adam
adam_opt = Adam(learning_rate=0.001,
beta_1=0.9, beta_2=0.9)
# In order to use SGD with a custom learning rate:
# sgd_opt = SGD(learning_rate=0.001)
model.compile(loss="categorical_crossentropy", optimizer=adam_opt)
Prétraitement des données#
En pratique, pour que la phase d’ajustement du modèle se déroule correctement, il est important de mettre à l’échelle les données d’entrée. Dans l’exemple suivant, nous allons comparer deux entraînements du même modèle, avec une initialisation similaire et la seule différence entre les deux sera de savoir si les données d’entrée sont centrées-réduites ou laissées telles quelles.
import pandas as pd
from keras.utils import to_categorical
iris = pd.read_csv("../data/iris.csv", index_col=0)
iris = iris.sample(frac=1)
y = to_categorical(iris["target"])
X = iris.drop(columns=["target"])
from keras.layers import Dense, InputLayer
from keras.models import Sequential
from keras.utils import set_random_seed
set_random_seed(0)
model = Sequential([
InputLayer(input_shape=(4, )),
Dense(units=256, activation="relu"),
Dense(units=256, activation="relu"),
Dense(units=256, activation="relu"),
Dense(units=3, activation="softmax")
])
n_epochs = 100
model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["accuracy"])
h = model.fit(X, y, epochs=n_epochs, batch_size=30, verbose=0)
Standardisons maintenant nos données et comparons les performances obtenues :
X -= X.mean(axis=0)
X /= X.std(axis=0)
set_random_seed(0)
model = Sequential([
InputLayer(input_shape=(4, )),
Dense(units=256, activation="relu"),
Dense(units=256, activation="relu"),
Dense(units=256, activation="relu"),
Dense(units=3, activation="softmax")
])
n_epochs = 100
model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["accuracy"])
h_standardized = model.fit(X, y, epochs=n_epochs, batch_size=30, verbose=0)
References#
Gabriel Goh. Why momentum really works. Distill, 2017. URL: http://distill.pub/2017/momentum.
Diederik P. Kingma and Jimmy Ba. Adam: a method for stochastic optimization. In Yoshua Bengio and Yann LeCun, editors, ICLR. 2015.