Travaux pratiques - Transfer learning et fine-tuning

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

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

  • L’ensemble d’apprentissage contient environ 27000 images. 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 TensorFlow via son module tensorflow-datasets (tfds).

%pip install tensorflow-datasets

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 (≈80Mo).

import matplotlib.pyplot as plt
import tensorflow_datasets as tfds

eurosat = tfds.load('eurosat', split='train').take(10)

fig = plt.figure(figsize=(18, 6))
for idx, data in enumerate(eurosat):
    fig.add_subplot(1, 10, idx+1)
    plt.imshow(data['image'])
    plt.axis("off")
    plt.title(data['filename'].numpy().decode("utf-8").split("_")[0])
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 Keras

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é dans le code source de Keras. Comme il s’agit d’un modèle assez répandu, il est directement implémenté dans Keras et il n’est pas nécessaire de le redéfinir. Ainsi, en passant par le sous-module keras.applications :

from tensorflow.keras.applications.resnet50 import ResNet50

model = ResNet50(include_top=True, weights='imagenet')

Question

À partir de la documentation de Tensorflow/Keras, déterminer l’utilité des paramètres include_top et weights.

model.summary()

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 qui réalise la classification dans les 1000 classes d’ImageNet.

Question

D’après l’architecture du modèle affichée par model.summary(), quelle sera la taille du vecteur de descripteurs ?

L’application du réseau sur chaque image de la base produira donc un vecteur de taille \(d\), 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. En utilisant l’interface fonctionnelle de Keras, cela correspond à créer un nouveau modèle dont la sortie est la sortie de l’avant-dernière couche du ResNet50, c’est-à-dire la sortie de model.layers[-2] (puisque model.layers[-1] est la dernière couche).

Autrement dit, notre nouveau modèle s’obtient de la façon suivante :

from keras.models import Model
model = Model(inputs=model.input, outputs=model.layers[-2].output)

Question

Afficher l’architecture de ce modèle et vérifier la dimension du descripteur en sortie. Compiler ce modèle ensuite ce modèle. On peut utiliser n’importe quelle fonction de coût et optimiseur car ils ne seront pas utilisés (on n’utilisera le modèle qu’en inférence pour cette partie).

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. Dans notre cas, nous allons utiliser la première moitié comme entraînement et la seconde moitié comme test.

Comme précédemment, nous utilisons tfds (tensorflow_datasets) pour définir un jeu de données au format TensorFlow, qui renvoie les couples (image, étiquette) sous forme de tenseurs :

batch_size = 32
train_dataset = tfds.load('eurosat', split='train[:50%]',
                          batch_size=batch_size,
                          shuffle_files=True).take(20)
test_dataset = tfds.load('eurosat', split='train[50%:]',
                         batch_size=batch_size,
                         shuffle_files=True).take(20)

n_train_data = len(train_dataset) * batch_size
n_test_data = len(test_dataset) * batch_size

Remarquons que nous ne conservons que les 20 premiers batches de chaque jeu de données (en apprentissage et en évaluation), ce qui correspond donc à \(20 \times 32 = 640\) examples chacun. 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 l’objet train_dataset pour extraire séquentiellement les deep features sur le jeu de données. En itérant, le jeu de données renvoie un dictionnaire d contenant deux entrées : d[image] qui contient le batch d’images et d[label] qui contient le batch des étiquettes de classe.

Comme les images du jeu de données n’ont pas la même taille que les images utilisées pour ImageNet, on prendra soin de redimensionner les images à l’aide de la fonction tf.image.resize de TensorFlow (cf. la documentation).

import numpy as np
from tqdm.auto import tqdm

d_size = 2048
X_train = np.zeros((n_train_data, d_size), dtype="float32")
y_train = np.zeros(n_train_data, dtype="uint8")

for idx, batch in enumerate(tqdm(train_dataset)):
    # TODO: compléter ce code
    ...

Question

Faire cette même opération sur test_dataset.

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 NumPy :

output_file = 'deepfeatures_resnet50_eurosat'
np.savez(output_file, X_train=X_train, y_train=y_train,
                      X_test=X_test, y_test=y_test)

Transfert de ImageNet vers EuroSAT

Si vous n’avez pas réussi la partie précédente, ou si jamais le temps de calcul était trop long car vous n’avez pas d’accès à un GPU, vous pouvez télécharger les deep features précalculées à l’adresse suivante : http://cedric.cnam.fr/vertigo/Cours/RCP209/docs/deepfeatures_resnet50_eurosat.npz

Puis vous pouvez les charger en mémoire :

!wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP209/docs/deepfeatures_resnet50_eurosat.npz

features = np.load("deepfeatures_resnet50_eurosat.npz")
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

# TODO: compléter
...

Question

pour approfondir, si vous avez le temps

Complémenter le code ci-dessous et implémenter un réseau simple (percepton à une seule couche) avec Keras. Entraîner ce modèle et évaluer ses performances en classification sur EuroSAT à partir des descripteurs extraits ci-dessus. Que constatez-vous ?

from keras.utils import to_categorical

y_train_one_hot = to_categorical(y_train)
y_test_one_hot = to_categorical(y_test)

# TODO: compléter 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 charger le modèle depuis Keras en l’initialisant à partir des poids appris sur ImageNet :

from tensorflow.keras.applications.resnet50 import ResNet50

# Chargement du modèle ResNet50 préentraîné sur ImageNet
model = ResNet50(include_top=True, weights='imagenet')

Question

Retirer la dernière couche du modèle (la couche entièrement connectée 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 classe dans EuroSAT).

Nous allons désormais spécifier que toutes les couches du réseau peuvent être apprises, et pas seulement la dernière que l’on vient d’ajouter :

for layer in model.layers:
    layer.trainable = True

Question

Si nous avions spécifié model.layers[i].trainable = False pour toutes les couches sauf la dernière, dans quel mode d’apprentissage serions-nous ?

Nous pouvons finalement compiler le modèle comme d’habitude :

from tensorflow.keras.optimizers import SGD

model.compile(loss='categorical_crossentropy', optimizer=SGD(learning_rate=0.01, momentum=0.9), metrics=['accuracy'])

Comme tout le jeu de données EuroSAT risque de ne pas rentrer en mémoire, il est préférable de charger les données à la volée en utilisant un générateur. La classe tensorflow.Dataset gère automatiquement cet aspect pour nous (ainsi que le multiprocessing pour charger les données en parallèle des calculs, de façon asynchrone).

train_dataset = tfds.load('eurosat', split='train[:50%]',
                          batch_size=batch_size,
                          shuffle_files=True,
                          as_supervised=True).take(20)
test_dataset = tfds.load('eurosat', split='train[50%:]',
                         batch_size=batch_size,
                         shuffle_files=True,
                         as_supervised=True).take(20)

train_ds = train_dataset.map(lambda image, label: (tf.image.resize(image, (224, 224)),
                                                   tf.one_hot(label, 10)))

Question

Que fait la méthode map dans le code ci-dessus ?

Finalement, on peut démarrer le fine-tuning en lançant l’apprentissage du modèle à l’aide de la méthode fit().

model.fit(train_ds, epochs=10)

Question

Évaluer les performances finales du modèle sur le jeu de test de EuroSAT. Commenter par rapport à l’approche précédente de transfer learning.