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.

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 ?

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() ?

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
...

Question

Faire cette même opération sur test_loader.

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?

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
...

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

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.

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 ?

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 ?

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.