XI.2 - Travaux pratiques#
Fonction d’activation ReLU#
Pour approfondir la séance précédente, si vous avez le temps.
Dans un premier temps, nous pouvons comparer la fonction d’activation
sigmoide utilisée dans les TP précédents et la fonction d’activation
ReLU (Rectifed Linear Unit, nn.ReLU() sous PyTorch).
Question
Reprendre le perceptron à une couche cachée du TP précédent en utilisant une non-linéarité ReLU. Observer le nombre d’itérations nécessaires pour atteindre la convergence du modèle.
Reprendre le réseau convolutif du TP précédent en utilisant des non-linéarités ReLU pour les couches convolutives. Observer le nombre d’itérations nécessaires pour atteindre la convergence du modèle.
Conclure sur l’intérêt de la fonction d’activation ReLU.
Transfer Learning et fine-tuning sur EuroSAT#
Pour aller plus loin, nous allons nous intéresser aux propriétés de « transfert » des réseaux convolutifs profonds pré-entraînés sur des bases large échelle comme ImageNet. Nous allons nous intéresser au jeu de données EuroSAT, qui est une base d’images contenant :
10 classes (bâtiments industriels, bâtiments résidentiels, cultures saisonnières, cultures permanentes, rivières, lacs & mers, végétation herbacée, routes/autoroutes, pâturages et forêts).
Environ 27000 images au total. Il n’y a pas d’ensemble de test prédéfini, nous allons donc la diviser en deux.
Les images sont de taille \(64\times64\). Les images que nous allons utiliser sont en couleur (RGB) mais il existe une version multispectrale utilisant 13 longueurs d’onde différentes.
Ce jeu de données est géré nativement par torchvision via la classe torchvision.datasets.EuroSAT.
import torch
from torchvision.datasets import EuroSAT
from torchvision.models import resnet50, ResNet50_Weights
# Graine aléatoire, pour la reproductibilité (tirage des exemples visualisés,
# découpage train/test, initialisation des couches réentraînées, ordre des batches)
torch.manual_seed(42)
# weights.transforms() renvoie le pré-traitement (redimensionnement, normalisation)
# attendu par le modèle pré-entraîné que nous utiliserons plus bas
weights = ResNet50_Weights.DEFAULT
preprocess = weights.transforms()
dataset = EuroSAT(root='data', download=True)
print(f"{len(dataset)} images, {len(dataset.classes)} classes : {dataset.classes}")
Nous pouvons commencer par visualiser quelques images de ce jeu de données. Si c’est la première fois que vous exécutez cette cellule de code, le téléchargement du jeu de données EuroSAT peut prendre quelques minutes (≈90Mo).
import matplotlib.pyplot as plt
import torch
indices = torch.randperm(len(dataset))[:10]
fig = plt.figure(figsize=(18, 6))
for i, idx in enumerate(indices):
image, label = dataset[idx]
fig.add_subplot(1, 10, i + 1)
plt.imshow(image)
plt.axis("off")
plt.title(dataset.classes[label])
plt.show()
Avec des « petits » volumes de données (de l’ordre de quelques milliers d’images), il est impossible d’entraîner des réseaux de neurones avec autant de paramètres que ceux utilisés pour ImageNet sans devoir se confronter au problème du sur-apprentissage. Toutefois, une solution généralement assez efficace consiste à appliquer de l’apprentissage par transfert. C’est une approche commune et peu coûteuse, qui permet d’exploiter des modèles « sur étagère » sans les ré-entraîner en les utilisant comme extracteur de caractéristiques.
Chargement du modèle ResNet-50 avec PyTorch#
Pour cette séance, nous allons nous appuyer sur une architecture de réseau convolutif qui obtient des performances plus que satisfaisantes sur ImageNet : les réseaux de la famille des ResNet. La compétition ILSVRC en 2015 (classification d’images naturelles sur les 1000 classes de ImageNet) a été remportée par un modèle type ResNet.
Nous allons repartir d’un modèle ResNet-50 dont l’architecture détaillée
peut être trouvée dans le code source de torchvision.
Comme il s’agit d’un modèle assez répandu, il est directement implémenté
dans torchvision.models et il n’est pas nécessaire de le redéfinir :
model = resnet50(weights=weights)
Question
À partir de la documentation de torchvision, déterminer l’utilité du paramètre weights.
Correction
weights permet de préciser les poids initiaux du modèle. Ici, en spécifiant ResNet50_Weights.DEFAULT, torchvision initialise le ResNet50 avec les meilleurs poids disponibles pré-entraînés sur le jeu de données ImageNet (l’équivalent de weights='imagenet' sous Keras). En passant weights=None, on obtiendrait un modèle avec la même architecture mais des poids initialisés aléatoirement.
print(model)
Extraction de deep features#
Une première solution pour surmonter le manque d’exemples d’apprentissage est de se servir des réseaux pré-entraînés sur ImageNet pour extraire des descripteurs d’image. En effet, la représentation apprise par les dernières couches du réseau contiennent normalement l’information utile à leur classification. Cette connaissance devrait pouvoir se transférer sur un autre jeu de données.
Nous allons ainsi appliquer le réseau ResNet50 et extraire, pour chaque
image de notre nouveau jeu de donnée, les activations de
l’avant-dernière couche, c’est-à-dire celle se trouvant juste avant la
couche linéaire model.fc qui réalise la classification dans les 1000 classes
d’ImageNet.
Question
D’après l’affichage de model ci-dessus, quelle est la couche qui précède model.fc ? Quelle sera la taille du vecteur de descripteurs qu’elle produit ?
Correction
La couche qui précède fc est avgpool, un average pooling qui produit 2048 activations (une par carte de caractéristiques de la dernière couche convolutive). Les descripteurs sont donc contenus dans un vecteur de taille 2048.
L’application du réseau sur chaque image de la base produira donc un
vecteur de taille \(d=2048\), que l’on appelle aussi deep feature («
caractéristique profonde »). Le modèle que nous allons utiliser est donc
notre ResNet50 mais pour lequel nous supprimons la dernière couche. Sous PyTorch, la façon la plus
simple de faire cela est de remplacer la couche fc par une couche « identité », qui ne fait rien
d’autre que renvoyer son entrée telle quelle :
from torch import nn
model.fc = nn.Identity()
model.eval() # on n'utilise le modèle qu'en inférence pour cette partie
Question
Pourquoi appelle-t-on model.eval() ici, plutôt que model.train() ?
Correction
model.eval() place le modèle en mode évaluation : certaines couches, comme le batch normalization (très utilisé dans ResNet50), se comportent différemment à l’apprentissage et à l’inférence. En mode évaluation, le batch normalization utilise les statistiques (moyenne, variance) accumulées pendant le pré-entraînement sur ImageNet, plutôt que celles du batch courant. Comme nous n’entraînons pas le modèle ici, nous voulons ce comportement.
Ensuite, nous pouvons créer les deux jeux de données d’entraînement et
d’évaluation de EuroSAT. Comme il n’y a pas de split fourni avec le
jeu de données, nous allons en créer un, en utilisant torch.utils.data.random_split
(un découpage aléatoire, contrairement à un découpage par indice, évite que le jeu d’apprentissage
ou de test ne se retrouve avec uniquement certaines classes, celles-ci étant rangées par dossier dans
le jeu de données d’origine) :
from torch.utils.data import DataLoader, Subset, random_split
dataset = EuroSAT(root='data', transform=preprocess)
generator = torch.Generator().manual_seed(0)
n_train = len(dataset) // 2
n_test = len(dataset) - n_train
train_dataset, test_dataset = random_split(dataset, [n_train, n_test], generator=generator)
# On se limite à 640 exemples de chaque côté (comme 20 batches de 32), pour
# limiter le temps de calcul sur cette séance
batch_size = 32
train_dataset = Subset(train_dataset, range(20 * batch_size))
test_dataset = Subset(test_dataset, range(20 * batch_size))
train_loader = DataLoader(train_dataset, batch_size=batch_size)
test_loader = DataLoader(test_dataset, batch_size=batch_size)
Remarquons que nous ne conservons que 640 exemples de chaque côté (apprentissage et évaluation). Le jeu de données EuroSAT contient bien plus d’images mais cet échantillon sera suffisant pour notre séance de TP. Par ailleurs, cela va nous permettre de mesurer l’intérêt du transfer learning lorsque l’on n’a accès qu’à peu de données.
Question
Compléter le code ci-dessous et itérer sur train_loader pour extraire séquentiellement les
deep features sur le jeu de données. En itérant, le DataLoader renvoie à chaque itération un
couple (images, labels) correspondant à un batch.
On pensera à englober le calcul dans un bloc with torch.no_grad():, pour éviter de calculer
inutilement les gradients (nous n’entraînons pas le modèle dans cette partie).
from tqdm.auto import tqdm
d_size = 2048
X_train = torch.zeros(len(train_dataset), d_size)
y_train = torch.zeros(len(train_dataset), dtype=torch.long)
# À compléter
...
Correction
from tqdm.auto import tqdm
d_size = 2048
X_train = torch.zeros(len(train_dataset), d_size)
y_train = torch.zeros(len(train_dataset), dtype=torch.long)
with torch.no_grad():
idx = 0
for images, labels in tqdm(train_loader):
# Extraction des deep features
features = model(images)
bs = images.shape[0]
# Insère les descripteurs dans la matrice
X_train[idx:idx + bs] = features
y_train[idx:idx + bs] = labels
idx += bs
Question
Faire cette même opération sur test_loader.
Correction
X_test = torch.zeros(len(test_dataset), d_size)
y_test = torch.zeros(len(test_dataset), dtype=torch.long)
with torch.no_grad():
idx = 0
for images, labels in tqdm(test_loader):
features = model(images)
bs = images.shape[0]
X_test[idx:idx + bs] = features
y_test[idx:idx + bs] = labels
idx += bs
Le temps d’extraction des caractéristiques peut être relativement long sur CPU (plusieurs minutes pour traiter l’intégralité du jeu de données par batch de 32 images). L’utilisation d’un GPU accélère considérablement le calcul. Pour éviter de recalculer les deep features, nous allons les sauvegarder à l’aide de PyTorch :
torch.save(
{"X_train": X_train, "y_train": y_train, "X_test": X_test, "y_test": y_test},
"deepfeatures_resnet50_eurosat.pt",
)
Transfert de ImageNet vers EuroSAT#
Vous pouvez recharger les deep features sauvegardées à l’étape précédente de la façon suivante :
features = torch.load("deepfeatures_resnet50_eurosat.pt")
X_train = features["X_train"]
y_train = features["y_train"]
X_test = features["X_test"]
y_test = features["y_test"]
Dans tous les cas, nous allons définir notre nouveau jeu de données qui prend comme observations les deep features calculées précédemment, et comme étiquettes celles du jeu de données EuroSAT.
Dans cette partie, nous allons considérer les deep features comme les données d’entrée.
Question
En utilisant scikit-learn, réaliser une visualisation t-SNE des
deep features ainsi obtenues. On réalisera une projection dans
un espace de dimension 2, puis on affichera les features projetées
en les colorant en fonction de leur classe.
Qu’observe-t-on? Que peut-on dire des représentations intermédiaires des images ainsi obtenues, par rapport aux images de départ?
Correction
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
tsne = TSNE(n_components=2, random_state=42)
projection = tsne.fit_transform(X_train.numpy())
plt.scatter(projection[:,0], projection[:,1], c=y_train.numpy())
plt.show()
Correction
Les différentes classes sont assez bien séparables dans la projection t-SNE. Autrement dit, les représentations apprises permettent de rendre voisine des images similaires. Les deep features permettent donc d’avoir une meilleure corrélation entre la sémantique et la distance dans l’espace latent.
Question
À l’aide de scikit-learn toujours, entraîner une SVM linéaire
simple qui va prédire les étiquettes de classes à partir des deep
features. On apprendra la SVM sur le jeu de test (X_train, y_train).
Évaluer le taux de bonne classification (accuracy) de ce classifieur
SVM sur le jeu de test (X_test, y_test) à l’aide de la méthode
.score().
from sklearn.svm import SVC
# À compléter
...
Correction
from sklearn.svm import SVC
clf = SVC(C=10)
clf.fit(X_train.numpy(), y_train.numpy())
clf.score(X_test.numpy(), y_test.numpy())
y_pred = clf.predict(X_test.numpy())
from sklearn.metrics import classification_report
print(classification_report(y_test.numpy(), y_pred))
Question
pour approfondir, si vous avez le temps
Compléter le code ci-dessous et implémenter un réseau simple (percepton à une seule couche) avec PyTorch. Entraîner ce modèle et évaluer ses performances en classification sur EuroSAT à partir des descripteurs extraits ci-dessus. Que constatez-vous ?
from torch.utils.data import TensorDataset, DataLoader
features_train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=batch_size, shuffle=True)
# À compléter en définissant et en entraînant un modèle de perceptron à une couche
Correction
from torch.utils.data import TensorDataset, DataLoader
features_train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=batch_size, shuffle=True)
clf_model = nn.Sequential(nn.Linear(d_size, 10))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(clf_model.parameters(), lr=0.1)
for epoch in range(20):
for batch_X, batch_y in features_train_loader:
outputs = clf_model(batch_X)
loss = criterion(outputs, batch_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
outputs = clf_model(X_test)
test_loss = criterion(outputs, y_test)
accuracy = (outputs.argmax(dim=1) == y_test).float().mean()
print(f"Scores finaux: accuracy = {100*accuracy.item():.2f}, loss={test_loss.item():.3f}")
Correction
Les performances sont quasiment identiques à celle de la SVM linéaire.
Fine-tuning#
Pour terminer ce TP, nous allons non plus simplement utiliser le modèle ResNet-50 préentraîné comme extracteur de caractéristiques « sur étagère », mais nous allons le spécialiser sur le jeu de données EuroSAT. Plus précisément, nous allons entraîner un ResNet-50 dont les paramètres seront initialisées à partir des poids appris sur la base ImageNet, puis nous allons entraîner pendant quelques itérations ce modèle sur EuroSAT pour spécialiser ses représentations internes sur la nouvelle base de données. Cela devrait notamment améliorer encore plus ses performances.
Note
L’exécution des apprentissages sur une carte graphique (GPU) est fortement recommandée pour cette partie.
Commençons par recharger le modèle depuis torchvision en l’initialisant à partir des poids appris sur ImageNet :
# Rechargement du modèle ResNet50 préentraîné sur ImageNet
model = resnet50(weights=weights)
Question
Retirer la dernière couche du modèle (la couche entièrement connectée fc qui renvoie les prédictions finales) et la remplacer par une nouvelle couche de taille adaptée. On remarquera que le modèle pré-entraîné renvoie 1000 probabilités (une pour chaque classe de ImageNet), tandis que le modèle que l’on souhaite fine-tune doit avoir 10 classes en sortie (10 classes dans EuroSAT). On utilisera model.fc.in_features pour récupérer la taille d’entrée de l’ancienne couche fc, sans avoir à l’écrire en dur.
Correction
# On remplace la dernière couche par une nouvelle couche entièrement connectée à 10 sorties
model.fc = nn.Linear(model.fc.in_features, 10)
Par défaut, tous les paramètres d’un modèle fraîchement créé (ou rechargé) sous PyTorch sont apprenables
(requires_grad=True) : nous allons donc, sans rien avoir de plus à faire, entraîner l’intégralité du
réseau, et pas seulement la couche fc que l’on vient d’ajouter.
Question
Si nous avions au contraire fixé param.requires_grad = False pour les paramètres de toutes les couches sauf model.fc, dans quel mode d’apprentissage serions-nous ?
Correction
Si seule la dernière couche peut être apprise, alors nous sommes exactement dans la même situation que deep features + un réseau avec une seule couche linéaire, c’est-à-dire le transfer learning déjà vu plus haut.
Nous pouvons finalement définir la fonction de coût et l’optimiseur comme d’habitude :
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
Comme tout le jeu de données EuroSAT risque de ne pas rentrer en
mémoire, il est préférable de charger les images à la volée plutôt que de toutes les garder en mémoire.
C’est exactement le rôle du couple Dataset/DataLoader que nous utilisons depuis le début de la
séance : EuroSAT(root='data', transform=preprocess) ne charge une image depuis le disque, la
redimensionne et la normalise que lorsqu’elle est effectivement demandée par le DataLoader.
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
Question
Que fait l’argument transform=preprocess passé à EuroSAT au tout début de la séance ?
Correction
transform=preprocess applique automatiquement, à chaque image chargée par le jeu de données, la fonction de pré-traitement attendue par le ResNet-50 pré-entraîné : redimensionnement des images \(64\times64\) de EuroSAT aux dimensions attendues par le réseau (\(224\times224\)), et normalisation des pixels avec les statistiques (moyenne, écart-type) calculées sur ImageNet. Contrairement à Keras, où les étiquettes doivent être explicitement encodées en one-hot, PyTorch nous laisse les étiquettes sous forme d’entiers : c’est nn.CrossEntropyLoss qui se charge du reste.
Finalement, on peut démarrer le fine-tuning en écrivant la boucle d’apprentissage habituelle.
n_epochs = 10
for epoch in range(n_epochs):
for images, labels in train_loader:
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"Epoch {epoch}: loss = {loss.item():.4f}")
Question
Évaluer les performances finales du modèle sur le jeu de test de EuroSAT (test_loader). Commenter par rapport à l’approche précédente de transfer learning.
Correction
correct, total = 0, 0
with torch.no_grad():
for images, labels in test_loader:
outputs = model(images)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += labels.shape[0]
print(f"Accuracy de test : {100 * correct / total:.2f}")
Correction
Les performances en fine-tuning sont supérieures à celles obtenues par un simple transfer learning.