Utilisation des fonctions d'activation dans les modèles d'apprentissage profond
Un modèle d'apprentissage profond dans sa forme la plus simple est constitué de couches de perceptrons connectés en tandem. Sans aucune fonction d'activation, ce ne sont que des multiplications matricielles à puissance limitée, quel que soit leur nombre. L'activation est la magie pour laquelle le réseau neuronal peut être une approximation d'une grande variété de fonctions non linéaires. Dans PyTorch, de nombreuses fonctions d'activation sont disponibles pour une utilisation dans vos modèles d'apprentissage en profondeur. Dans cet article, vous verrez comment le choix des fonctions d’activation peut impacter le modèle. Spécifiquement,
- Quelles sont les fonctions d'activation courantes
- Quelle est la nature des fonctions d'activation
- Comment les différentes fonctions d'activation impactent le taux d'apprentissage
- Comment la sélection de la fonction d'activation peut résoudre le problème du gradient de disparition
Démarrez votre projet avec mon livre Deep Learning with PyTorch. Il fournit des tutoriels d'auto-apprentissage avec du code fonctionnel.
Aperçu
Cet article est composé de trois parties ; ils sont
- Un modèle jouet de classification binaire
- Pourquoi des fonctions non linéaires ?
- L'effet des fonctions d'activation
Un modèle jouet de classification binaire
Commençons par un exemple simple de classification binaire. Ici, vous utilisez la fonction make_circle()
de scikit-learn pour créer un ensemble de données synthétiques pour la classification binaire. Cet ensemble de données comporte deux fonctionnalités : Les coordonnées x et y des points. Chaque point appartient à l'une des deux classes. Vous pouvez générer 1 000 points de données et les visualiser comme ci-dessous :
from sklearn.datasets import make_circles
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
# Make data: Two circles on x-y plane as a classification problem
X, y = make_circles(n_samples=1000, factor=0.5, noise=0.1)
X = torch.tensor(X, dtype=torch.float32)
y = torch.tensor(y.reshape(-1, 1), dtype=torch.float32)
plt.figure(figsize=(8,6))
plt.scatter(X[:,0], X[:,1], c=y)
plt.show()
L'ensemble de données est visualisé comme suit :
Cet ensemble de données est particulier car il est simple mais non linéairement séparable : il est impossible de trouver une ligne droite pour séparer deux classes. Comment votre réseau neuronal peut-il comprendre qu'il existe une limite circulaire entre les classes est un défi.
Créons un modèle d'apprentissage en profondeur pour ce problème. Pour simplifier les choses, vous ne faites pas de validation croisée. Vous constaterez peut-être que le réseau neuronal suradapte les données, mais cela n'affecte pas la discussion ci-dessous. Le modèle comporte 4 couches cachées et la couche de sortie donne une valeur sigmodale (0 à 1) pour la classification binaire. Le modèle accepte un paramètre chez son constructeur pour spécifier quelle est l'activation à utiliser dans les couches cachées. Vous implémentez la boucle de formation dans une fonction car vous l'exécuterez plusieurs fois.
La mise en œuvre est la suivante :
class Model(nn.Module):
def __init__(self, activation=nn.ReLU):
super().__init__()
self.layer0 = nn.Linear(2,5)
self.act0 = activation()
self.layer1 = nn.Linear(5,5)
self.act1 = activation()
self.layer2 = nn.Linear(5,5)
self.act2 = activation()
self.layer3 = nn.Linear(5,5)
self.act3 = activation()
self.layer4 = nn.Linear(5,1)
self.act4 = nn.Sigmoid()
def forward(self, x):
x = self.act0(self.layer0(x))
x = self.act1(self.layer1(x))
x = self.act2(self.layer2(x))
x = self.act3(self.layer3(x))
x = self.act4(self.layer4(x))
return x
def train_loop(model, X, y, n_epochs=300, batch_size=32):
loss_fn = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)
batch_start = torch.arange(0, len(X), batch_size)
bce_hist = []
acc_hist = []
for epoch in range(n_epochs):
# train model with optimizer
model.train()
for start in batch_start:
X_batch = X[start:start+batch_size]
y_batch = y[start:start+batch_size]
y_pred = model(X_batch)
loss = loss_fn(y_pred, y_batch)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# evaluate BCE and accuracy at end of each epoch
model.eval()
with torch.no_grad():
y_pred = model(X)
bce = float(loss_fn(y_pred, y))
acc = float((y_pred.round() == y).float().mean())
bce_hist.append(bce)
acc_hist.append(acc)
# print metrics every 10 epochs
if (epoch+1) % 10 == 0:
print("Before epoch %d: BCE=%.4f, Accuracy=%.2f%%" % (epoch+1, bce, acc*100))
return bce_hist, acc_hist
À la fin de chaque époque dans la fonction de formation, vous évaluez le modèle avec l'ensemble de données. Le résultat de l'évaluation est renvoyé une fois la formation terminée. Dans ce qui suit, vous allez créer un modèle, l'entraîner et tracer l'historique de l'entraînement. La fonction d'activation que vous utilisez est l'unité linéaire rectifiée ou ReLU, qui est la fonction d'activation la plus courante de nos jours :
activation = nn.ReLU
model = Model(activation=activation)
bce_hist, acc_hist = train_loop(model, X, y)
plt.plot(bce_hist, label="BCE")
plt.plot(acc_hist, label="Accuracy")
plt.xlabel("Epochs")
plt.ylim(0, 1)
plt.title(str(activation))
plt.legend()
plt.show()
L'exécution de ceci vous donne ce qui suit :
Before epoch 10: BCE=0.7025, Accuracy=50.00%
Before epoch 20: BCE=0.6990, Accuracy=50.00%
Before epoch 30: BCE=0.6959, Accuracy=50.00%
...
Before epoch 280: BCE=0.3051, Accuracy=96.30%
Before epoch 290: BCE=0.2785, Accuracy=96.90%
Before epoch 300: BCE=0.2543, Accuracy=97.00%
et cette intrigue :
Ce modèle fonctionne très bien. Après 300 époques, il peut atteindre une précision de 90 %. Cependant, ReLU n'est pas la seule fonction d'activation. Historiquement, la fonction sigmoïde et les tangentes hyperboliques étaient courantes dans la littérature sur les réseaux neuronaux. Si vous êtes curieux, voici comment comparer ces trois fonctions d'activation, en utilisant matplotlib :
x = torch.linspace(-4, 4, 200)
relu = nn.ReLU()(x)
tanh = nn.Tanh()(x)
sigmoid = nn.Sigmoid()(x)
plt.plot(x, sigmoid, label="sigmoid")
plt.plot(x, tanh, label="tanh")
plt.plot(x, relu, label="ReLU")
plt.ylim(-1.5, 2)
plt.legend()
plt.show()
ReLU est appelé unité linéaire rectifiée car c'est une fonction linéaire $y=x$à $x$positif mais reste nulle si $x$est négatif. Mathématiquement, c'est $y=\max(0, x)$. La tangente hyperbolique ($y=\tanh(x)=\dfrac{e^x – e^{-x}}{e^x+e^{-x}}$) passe de -1 à +1 en douceur pendant la sigmoïde la fonction ($y=\sigma(x)=\dfrac{1}{1+e^{-x}}$) passe de 0 à +1.
Si vous essayez de différencier ces fonctions, vous constaterez que ReLU est la plus simple : le gradient est de 1 dans la région positive et de 0 dans le cas contraire. La tangente hyperbolique a une pente plus raide donc son gradient est supérieur à celui de la fonction sigmoïde.
Toutes ces fonctions se multiplient. Leurs gradients ne sont donc jamais négatifs. C'est l'un des critères pour une fonction d'activation pouvant être utilisée dans les réseaux de neurones.
Pourquoi des fonctions non linéaires ?
Vous vous demandez peut-être pourquoi tout ce battage médiatique autour des fonctions d'activation non linéaires ? Ou pourquoi ne pouvons-nous pas simplement utiliser une fonction d’identité après la combinaison linéaire pondérée des activations de la couche précédente ? L’utilisation de plusieurs couches linéaires revient fondamentalement à utiliser une seule couche linéaire. Cela peut être vu à travers un exemple simple. Disons que vous disposez d’un réseau neuronal à une seule couche cachée, chacune avec deux neurones cachés.
Vous pouvez ensuite réécrire la couche de sortie sous la forme d'une combinaison linéaire de la variable d'entrée d'origine si vous avez utilisé une couche masquée linéaire. Si vous aviez plus de neurones et de poids, l'équation serait beaucoup plus longue avec plus d'imbrications et plus de multiplications entre les poids des couches successives. Cependant, l'idée reste la même : vous pouvez représenter l'ensemble du réseau comme une seule couche linéaire. Pour que le réseau représente des fonctions plus complexes, vous auriez besoin de fonctions d'activation non linéaires.
L'effet des fonctions d'activation
Pour expliquer l'impact que la fonction d'activation peut apporter à votre modèle, modifions la fonction de boucle d'entraînement pour capturer plus de données : les gradients dans chaque étape d'entraînement. Votre modèle comporte quatre couches cachées et une couche de sortie. À chaque étape, le passage en arrière calcule le gradient des poids de chaque couche et la mise à jour des poids est effectuée par l'optimiseur en fonction du résultat du passage en arrière. Vous devez observer comment la pente change à mesure que l'entraînement progresse. Par conséquent, la fonction de boucle d'entraînement est modifiée pour collecter la valeur absolue moyenne du gradient dans chaque couche à chaque étape, comme suit :
def train_loop(model, X, y, n_epochs=300, batch_size=32):
loss_fn = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)
batch_start = torch.arange(0, len(X), batch_size)
bce_hist = []
acc_hist = []
grad_hist = [[],[],[],[],[]]
for epoch in range(n_epochs):
# train model with optimizer
model.train()
layer_grad = [[],[],[],[],[]]
for start in batch_start:
X_batch = X[start:start+batch_size]
y_batch = y[start:start+batch_size]
y_pred = model(X_batch)
loss = loss_fn(y_pred, y_batch)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# collect mean absolute value of gradients
layers = [model.layer0, model.layer1, model.layer2, model.layer3, model.layer4]
for n,layer in enumerate(layers):
mean_grad = float(layer.weight.grad.abs().mean())
layer_grad[n].append(mean_grad)
# evaluate BCE and accuracy at end of each epoch
model.eval()
with torch.no_grad():
y_pred = model(X)
bce = float(loss_fn(y_pred, y))
acc = float((y_pred.round() == y).float().mean())
bce_hist.append(bce)
acc_hist.append(acc)
for n, grads in enumerate(layer_grad):
grad_hist[n].append(sum(grads)/len(grads))
# print metrics every 10 epochs
if epoch % 10 == 9:
print("Epoch %d: BCE=%.4f, Accuracy=%.2f%%" % (epoch, bce, acc*100))
return bce_hist, acc_hist, layer_grad
À la fin de la boucle for interne, les dégradés des poids de couche sont calculés plus tôt par le processus inverse et vous pouvez accéder au dégradé en utilisant model.layer0.weight.grad
. Comme les poids, les dégradés sont des tenseurs. Vous prenez la valeur absolue de chaque élément, puis calculez la moyenne de tous les éléments. Cette valeur dépend du lot et peut être très bruyante. Ainsi, vous résumez toutes ces valeurs absolues moyennes sur la même époque à la fin.
Notez que vous disposez de cinq couches dans le réseau neuronal (couches cachées et de sortie combinées). Ainsi, vous pouvez voir le motif du dégradé de chaque couche à travers les époques si vous les visualisez. Ci-dessous, vous exécutez la boucle d'entraînement comme avant et tracez à la fois l'entropie croisée et la précision ainsi que le gradient absolu moyen de chaque couche :
activation = nn.ReLU
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
L'exécution de ce qui précède produit le tracé suivant :
Dans le graphique ci-dessus, vous pouvez voir comment la précision augmente et la perte d’entropie croisée diminue. Dans le même temps, vous pouvez voir que le dégradé de chaque calque fluctue dans une plage similaire, vous devez notamment faire attention à la ligne correspondant au premier calque et au dernier calque. Ce comportement est idéal.
Répétons la même chose avec une activation sigmoïde :
activation = nn.Sigmoid
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
dont l'intrigue est la suivante :
Vous pouvez voir qu'après 300 époques, le résultat final est bien pire que l'activation de ReLU. En effet, il faudra peut-être beaucoup plus d’époques pour que ce modèle converge. La raison peut être facilement trouvée sur le graphique de droite, où vous pouvez voir que le dégradé n'est significatif que pour la couche de sortie alors que les dégradés de toutes les couches cachées sont pratiquement nuls. Il s'agit de l'effet de gradient de disparition qui constitue le problème de nombreux modèles de réseaux neuronaux dotés d'une fonction d'activation sigmoïde.
La fonction tangente hyperbolique a une forme similaire à la fonction sigmoïde mais sa courbe est plus raide. Voyons comment il se comporte :
activation = nn.Tanh
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
Ce qui est :
Le résultat semble meilleur que l'activation sigmoïde mais encore pire que ReLU. En fait, à partir du tracé du dégradé, vous pouvez remarquer que les dégradés au niveau des calques cachés sont significatifs, mais le dégradé au niveau du premier calque caché est évidemment d'un ordre de grandeur inférieur à celui de la couche de sortie. Ainsi, le processus inverse n’est pas très efficace pour propager le gradient vers l’entrée.
C'est la raison pour laquelle vous voyez aujourd'hui l'activation de ReLU dans chaque modèle de réseau neuronal. Non seulement parce que ReLU est plus simple et que le calcul de sa différenciation est beaucoup plus rapide que l'autre fonction d'activation, mais aussi parce qu'il peut faire converger le modèle plus rapidement.
En effet, vous pouvez parfois faire mieux que ReLU. Dans PyTorch, vous disposez d'un certain nombre de variantes de ReLU. Examinons-en deux. Vous pouvez comparer ces trois variantes de ReLU comme suit :
x = torch.linspace(-8, 8, 200)
relu = nn.ReLU()(x)
relu6 = nn.ReLU6()(x)
leaky = nn.LeakyReLU()(x)
plt.plot(x, relu, label="ReLU")
plt.plot(x, relu6, label="ReLU6")
plt.plot(x, leaky, label="LeakyReLU")
plt.legend()
plt.show()
activation = nn.ReLU6
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
activation = nn.LeakyReLU
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
Vous pouvez voir que toutes ces variations peuvent vous donner une précision similaire après 300 époques, mais d'après la courbe historique, vous savez que certaines sont plus rapides à atteindre une précision élevée que d'autres. Cela est dû à l'interaction entre le gradient d'une fonction d'activation et l'optimiseur. Il n’y a pas de règle d’or selon laquelle une seule fonction d’activation fonctionne mieux, mais la conception aide :
- en rétropropagation, en passant la métrique de perte de la couche de sortie jusqu'à la couche d'entrée
- maintenir un calcul de gradient stable dans des conditions spécifiques, par exemple en limitant la précision de la virgule flottante
- fournir suffisamment de contraste sur différentes entrées pour que le passage en arrière puisse déterminer un ajustement précis du paramètre
Voici le code complet pour générer tous les tracés ci-dessus :
from sklearn.datasets import make_circles
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
# Make data: Two circles on x-y plane as a classification problem
X, y = make_circles(n_samples=1000, factor=0.5, noise=0.1)
X = torch.tensor(X, dtype=torch.float32)
y = torch.tensor(y.reshape(-1, 1), dtype=torch.float32)
# Binary classification model
class Model(nn.Module):
def __init__(self, activation=nn.ReLU):
super().__init__()
self.layer0 = nn.Linear(2,5)
self.act0 = activation()
self.layer1 = nn.Linear(5,5)
self.act1 = activation()
self.layer2 = nn.Linear(5,5)
self.act2 = activation()
self.layer3 = nn.Linear(5,5)
self.act3 = activation()
self.layer4 = nn.Linear(5,1)
self.act4 = nn.Sigmoid()
def forward(self, x):
x = self.act0(self.layer0(x))
x = self.act1(self.layer1(x))
x = self.act2(self.layer2(x))
x = self.act3(self.layer3(x))
x = self.act4(self.layer4(x))
return x
# train the model and produce history
def train_loop(model, X, y, n_epochs=300, batch_size=32):
loss_fn = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)
batch_start = torch.arange(0, len(X), batch_size)
bce_hist = []
acc_hist = []
grad_hist = [[],[],[],[],[]]
for epoch in range(n_epochs):
# train model with optimizer
model.train()
layer_grad = [[],[],[],[],[]]
for start in batch_start:
X_batch = X[start:start+batch_size]
y_batch = y[start:start+batch_size]
y_pred = model(X_batch)
loss = loss_fn(y_pred, y_batch)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# collect mean absolute value of gradients
layers = [model.layer0, model.layer1, model.layer2, model.layer3, model.layer4]
for n,layer in enumerate(layers):
mean_grad = float(layer.weight.grad.abs().mean())
layer_grad[n].append(mean_grad)
# evaluate BCE and accuracy at end of each epoch
model.eval()
with torch.no_grad():
y_pred = model(X)
bce = float(loss_fn(y_pred, y))
acc = float((y_pred.round() == y).float().mean())
bce_hist.append(bce)
acc_hist.append(acc)
for n, grads in enumerate(layer_grad):
grad_hist[n].append(sum(grads)/len(grads))
# print metrics every 10 epochs
if epoch % 10 == 9:
print("Epoch %d: BCE=%.4f, Accuracy=%.2f%%" % (epoch, bce, acc*100))
return bce_hist, acc_hist, layer_grad
# pick different activation functions and compare the result visually
for activation in [nn.Sigmoid, nn.Tanh, nn.ReLU, nn.ReLU6, nn.LeakyReLU]:
model = Model(activation=activation)
bce_hist, acc_hist, grad_hist = train_loop(model, X, y)
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
ax[0].plot(bce_hist, label="BCE")
ax[0].plot(acc_hist, label="Accuracy")
ax[0].set_xlabel("Epochs")
ax[0].set_ylim(0, 1)
for n, grads in enumerate(grad_hist):
ax[1].plot(grads, label="layer"+str(n))
ax[1].set_xlabel("Epochs")
fig.suptitle(str(activation))
ax[0].legend()
ax[1].legend()
plt.show()
Lectures complémentaires
Cette section fournit plus de ressources sur le sujet si vous souhaitez approfondir.
- nn.Sigmoïde de la documentation PyTorch
- nn.Tanh de la documentation PyTorch
- nn.ReLU de la documentation PyTorch
- nn.ReLU6 de la documentation PyTorch
- nn.LeakyReLU de la documentation PyTorch
- Problème de gradient de disparition, Wikipédia
Résumé
Dans ce chapitre, vous avez découvert comment sélectionner les fonctions d'activation pour votre modèle PyTorch. Vous avez appris :
- Quelles sont les fonctions d'activation courantes et à quoi ressemblent-elles
- Comment utiliser les fonctions d'activation dans votre modèle PyTorch
- Qu'est-ce que le problème de gradient de disparition
- L'impact de la fonction d'activation sur les performances de votre modèle