Session 2 : Transfer Learning

Objectif : Mettre en œuvre le transfer learning en réutilisant un backbone pré-entraîné dans le cadre d’un problème de classification d’images.

Choix du dataset

Nous ne retenons pas CIFAR-10 pour cette étude.

En effet :

  • La résolution des images (32×32) est très faible

  • La complexité du problème reste limitée

  • Des architectures légères, comme nos TinyCNN correctement optimisés, atteignent déjà des performances élevées

L’apport du transfer learning y est donc peu significatif.

Nous nous orientons vers le dataset Oxford-IIIT Pet, plus adapté pour mettre en évidence l’intérêt du transfer learning. Ce choix se justifie par plusieurs caractéristiques :

  • Résolution d’image plus élevée : images naturelles de grande taille

  • Nombre d’exemples limité par classe : ~200 images

  • Classes visuellement proches : races de chiens et de chats similaires

  • Variabilité importante : pose, éclairage, arrière-plan

Terminologie

  • Le backbone est la partie du réseau qui extrait les caractéristiques (apprentissage des features)

  • Le head est la partie finale du réseau qui transforme les features en décision (classiquement du FC)

Nous appelons Transfer learning (TL), le fait de réutiliser un modèle pré-entraîné pour une nouvelle tâche. Cette approche peut prendre deux formes :

  • Feature extractor : backbone complètement gelé, on entraîne uniquement la tête

  • Fine-tuning : on débloque certaines couches du backbone pour adapter les features à la nouvelle tâche

Deux contextes :

  • Feature extractor : plus simple, plus stable, plus rapide

  • Fine-tuning : plus puissant mais plus complexe à régler

En transfer learning, on commence toujours par l’étape de feature extraction. Le fine-tuning vient ensuite, c’est une amélioration, pas un point de départ.

Choix du modèle

Le terrain

En vision par ordinateur, le choix du modèle dépend fortement des ressources disponibles et du niveau de performance attendu. Nous allons nous concentrer sur des réseaux allant du léger au milieu de gamme, offrant un bon compromis entre performance et coût de calcul.

Par ordre croissant du nombre de paramètres :

  • MobileNetV2, conçu pour être léger et efficace, particulièrement adapté aux environnements contraints

  • ResNet18, qui reste relativement compact tout en étant discriminant

  • ResNet34, plus profond et donc plus riche en paramètres, offrant un meilleur compromis entre capacité et coût de calcul

  • ResNet50 avec une architecture plus complexe, mais nécessitant des ressources matérielles plus conséquentes

MobileNetV2 ou ResNet18

Ces deux modèles sont bien adaptés aux datasets de petite taille et offrent des performances globalement comparables en transfer learning.

Cependant, leurs propriétés diffèrent légèrement :

  • MobileNetV2 est une architecture légère, optimisée pour la rapidité et l’efficacité, mais avec une capacité d’analyse plus limitée

  • ResNet18 capture mieux les détails visuels fins

Dans le cadre de notre problématique — classification de races de chiens et de chats aux caractéristiques parfois très proches — la capacité à discriminer des différences subtiles est essentielle.

Nous retenons donc ResNet18.

Pour réduire encore la quantité de calculs, nous allons injecter des images 160x160 dans le réseau au lieu des 224x224 attendu, cela diminuant par 2 la quantité de pixels à traiter !

Phase 1 - Mise en place

Feature extraction

On conserve les features apprises et on n’entraîne uniquement le classifier FC. Ainsi, on désactive l’utilisation du gradient dans tout le modèle sauf pour le head :

# on gele toutes les couches
for param in model.parameters():
    param.requires_grad = False

# on réactive uniquement la tête
for param in model.fc.parameters():
    param.requires_grad = True

Dropout et BatchNorm

Il existe un point qui va s’avérer important. L’idée de l’étape de Feature Extraction est de figer le backbone. Or ce backbone contient des couches Dropout et BatchNorm qui ne sont pas désactivées par la simple application de requires_grad = False :

  • Les couches BatchNorm mettent à jour leurs valeurs internes à chaque batch en mode train

  • Les couches Dropout désactivent aléatoirement des neurones en mode train

Pour que ces couches spécifiques n’évoluent pas durant l’apprentissage, on va les forcer en mode eval.

Cependant, durant l’apprentissage, le modèle passe successivement du mode Train au mode eval. Il va donc falloir réécrire ces fonctions pour que le backbone reste tout le temps en mode eval.

Dans la libraire PyTorch, l’appel de model.eval() étant en fait redirigé vers model.train(False), nous devons donc uniquement reprogrammer la fonction train de notre modèle :

def train(self, mode: bool = True):
    super().train(mode)

    if mode:                       # mode train
        self.model.eval()          # fige backbone + head
        self.model.fc.train(True)  # on réactive head

    else:                          # mode eval
        self.model.eval()          # en validation, tout est en eval

Classe

Nous regroupons toutes ces modifications au sein d’une classe pour faciliter l’utilisation de modèle :

from torchvision import models

class ResNet18FTransfer(nn.Module):
    def __init__(self, num_classes: int = 37):
        super().__init__()

        weights = models.ResNet18_Weights.DEFAULT
        self.model    = models.resnet18(weights=weights)
        in_features   = self.model.fc.in_features
        self.model.fc = nn.Linear(in_features, num_classes)
        self.freeze_backbone()

    def freeze_backbone(self):
        for param in self.model.parameters():
            param.requires_grad = False
        for param in self.model.fc.parameters():
            param.requires_grad = True

    def train(self, mode: bool = True):
        super().train(mode)

        if mode:                       # mode train
            self.model.eval()          # fige backbone + head
            self.model.fc.train(True)  # on réactive head

        else:                          # mode eval
            self.model.eval()          # en validation, tout est en eval

    def forward(self, x):
        return self.model(x)

Hyperparamètres

  • Batch size : 32

  • Optimiseur : Adam - LR : 10⁻³

  • Train / Val : train et test d’Oxford pets

  • Nombre epochs : 20

  • Loss fonction : CrossEntropyLoss

Premier run

../_images/start.png

Commentaires :

  • La convergence est ultra rapide, quelques epochs seulement

  • Evidemment, le surapprentissage est au rdv

  • AVEC le gel des couches DropOut et BatchNorm, on atteint une performance de 87%

  • SANS le gel de ces couches, les performances sont moindres de 4 points

Résultats

Métrique

Valeur

Transfer Learning - FC basique

Accuracy

0.8670

En conclusion, la couche FC exploite très bien et très rapidement les features de ResNet18 atteignant 87% de Accuracy/validation en seulement une douzaine d’epochs.

Note

En conclusion, pour la phase de Feature Extraction, il semble préférable de geler entièrement le backbone y compris les couches DropOut et BatchNorm.

Résolution

Est-ce qu’utiliser la résolution de 224×224 avec laquelle a été entraîné ResNet18 permet d’améliorer les performances ?

Résultats

Métrique

Valeur

Transfer Learning - FC - res 224x224

Accuracy

0.895

Utiliser la résolution d’origine apporte un gain d’environ 3 points de performance. Nous allons donc finalement travailler en 224×224.

Source

Vous pouvez télécharger le code source associé à ce projet.

Ordre habituel de progression

Comme pour le projet TinyCNN, nous conseillons — sans que cela soit obligatoire — d’effectuer les tests dans un certain ordre :

  • En premier lieu, optimiser le LR

  • Ensuite, optimiser la convergence : Scheduler,…

  • Puis Data Augmentation

  • Ensuite : Fine Tuning

  • Eventuellement : une longue liste de méthodes avancées

Phase 2 - Optimisation du LR

Nous testons différentes valeurs :

Learning Rate

0.01

0.003

0.001

0.0003

0.0001

Best Accuracy

0.866

0.892

0.893

0.899

0.900

Les performances restent similaires sur une grande plage de valeurs : de 3.10⁻³ à 10⁻⁴.

Note

Cette étape est optionnelle. Pour votre projet, vous pouvez utiliser directement sans justification la valeur par défaut d’Adam : LR=10⁻³ qui fonctionne très bien pour l’étape de Feature Extraction.

Amélioration

Nous faisons trois tentatives pour essayer d’améliorer les performances actuelles :

Data Augmentation

Nous utilisons les transformations déjà présentées dans la section DataSet :

T_train = transforms.Compose([
    transforms.RandomResizedCrop(160, scale=(0.8, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.02 ),
    transforms.ToTensor(),
    transforms.Normalize(mean=imagenet_mean, std=imagenet_std),
])

Amélioration de la tête

On se propose d’utiliser deux couches FC en sortie afin d’augmenter la capacité d’apprentissage de la tête

self.model.fc = nn.Sequential(
    nn.Linear(in_features, 256),
    nn.ReLU(),
    nn.Linear(256, num_classes)
)

Scheduler

Nous choisissons un scheduler StepLR qui permet de générer 3 niveaux de LR :

  • Epoch 1 à 10 : 10-3

  • Epoch 11 à 20 : 10-4

  • Epoch 21 à 30 : 10-5

S.scheduler = torch.optim.lr_scheduler.StepLR(S.optimizer, step_size=10, gamma=0.1 )

Bilan

Dans les trois cas, nous obtenons des performances similaires :

Méthode

Data Augmentation

Scheduler

Double FC

Best Accuracy

0.893

0.899

0.895

Étonnamment, l’ajout de ces trois techniques n’apporte aucun gain significatif. Les performances sont similaires, comme si ces modifications n’avaient eu aucun effet. Que peut-on supposer ?

Resnet18

Examinons les blocs présents dans ce réseau :

for name, module in model.model.named_children():
    print(name)


>> conv1
>> bn1
>> relu
>> maxpool
>> layer1
>> layer2
>> layer3
>> layer4
>> avgpool
>> fc

Entrée

On trouve ainsi une première série de couches que nous avons déjà rencontrée dans le TinyCNN :

  • conv1 → bn1 → relu → maxpool

Cette séquence correspond à une étape classique de traitement initial : une première convolution suivie d’une normalisation, d’une fonction d’activation, puis d’une réduction de la résolution. On retrouve ici des opérations similaires à celles utilisées dans le TinyCNN, bien que leur rôle soit ici de préparer les données avant leur traitement par les blocs résiduels.

Sortie

De la même manière, on trouve en sortie du réseau, les mêmes couches que pour le TinyCNN : avgpool → fc

Blocs intermédiaires

Dans Resnet18, on trouve ensuite quatre blocs de même structure : layer1, layer2, layer3 et layer4.

Ces blocs sont appelés blocs résiduels et reposent sur l’utilisation de skip connections. Pour comprendre ce principe, voici le code définissant ces blocs :

def forward(self, input):

        out = self.conv1(input)
        out = self.bn1(out)
        out = F.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        out = out + input   # skip connection
        out = F.relu(out)

        return out

La technique des skip connections consiste à ajouter l’entrée du bloc à sa sortie, juste avant l’application de la fonction d’activation. La couche suivante reçoit ainsi une correction des features plutôt qu’une transformation complète.

Phase 2 - Fine tuning partiel

Principe

Pour faire simple, les 4 blocs résiduels stockent des features de plus en plus haut niveau :

  • conv1 + layer1 : bords, textures

  • layer2 + layer3 : formes

  • layer4 : concepts spécifiques, ex: tête du chien

Pourquoi ne pas dégeler toutes les couches ? Voici les risques :

  • Instabilité

  • Surapprentissage rapide

  • Perte des features ImageNet

  • Temps de calcul ↑

Pourquoi ne dégeler que layer4 ?

  • Layer4 contient les features les plus spécifiques

  • Layer4 est proche de la sortie

  • Layer4 a peu de paramètres comparé au réseau entier

Le nouveau modèle

class ResNet18FTransfer(nn.Module):
    def __init__(self, num_classes: int = 37):
        super().__init__()

        weights = models.ResNet18_Weights.DEFAULT
        self.model = models.resnet18(weights=weights)
        in_features = self.model.fc.in_features
        self.model.fc = nn.Linear(in_features, num_classes)
        self.setup_finetuning()


    def setup_finetuning(self):
        for param in self.model.parameters():        # Gèle tout le réseau
            param.requires_grad = False

        for param in self.model.fc.parameters():     # Réactive la tête de classification
            param.requires_grad = True

        for param in self.model.layer4.parameters(): # Réactive le dernier bloc résiduel
            param.requires_grad = True

    '''
    def train(self, mode: bool = True):
        super().train(mode)

        if mode:
            self.model.eval()              # tout en eval par défaut
            self.model.layer4.train(True)  # layer4 en entraînement
            self.model.fc.train(True)      # tête en entraînement
        else:
            self.model.eval()              # validation : tout en eval

        return self
    '''

    def forward(self, x):
        return self.model(x)

Deux approches existent pour le fine-tuning : soit laisser actives les couches DropOut et BatchNorm des couches inférieures, soit ne dégeler que celles de layer4. Il n’y a pas de méthode universellement meilleure : l’une peut bien fonctionner dans un cas, et moins bien dans un autre.

Note

L’approche la plus courante consiste à laisser actives toutes les couches DropOut et BatchNorm du backbone. Cet usage est effectivement à l’opposé de celui considéré dans l’étape de Feature Extraction.

Optimisation du LR

Un dilemme apparaît :

  • la tête du réseau (couche FC), initialisée aléatoirement, nécessite un learning rate relativement élevé pour apprendre efficacement

  • la couche layer4, déjà pré-entraînée, doit être ajustée avec un learning rate plus faible afin de ne pas dégrader les features acquises

Il est donc nécessaire de rechercher un LR compatible avec ces deux contraintes :

../_images/testLR_FT.png

Commentaires :

  • On constate que le LR habituel de 10⁻³ fonctionne mal, il est trop élevé pour la couche layer4 ce qui crée de fortes oscillations et une baisse de performance.

  • Nous choisissons donc un LR de 10⁻⁴ qui semble convenir, ni trop haut, ni trop base

  • La valeur de Best Acc stagne à 90%, pas d’amélioration à ce niveau

Amélioration

L’histoire se répète, en testant plusieurs améliorations (Data Augmentation, scheduler), la Best Accuracy plafonne à 90%. Impossible de franchir ce mur, nous devons peut être accepté que nous avons atteint les performances maximales que peut offrir le modèle ResNet18. L’étape suivante va donc consister à migrer vers un modèle plus puissant.

Resnet34/50

Mise en place

Notre code étant suffisament générique, la migration se fait en changeant seulement quelques lignes :

class ResNetTransfer(nn.Module):
def __init__(self, num_classes: int = 37):
    super().__init__()

    weights = models.ResNet34_Weights.DEFAULT
    self.model = models.resnet34(weights=weights)
    in_features = self.model.fc.in_features
    self.model.fc = nn.Linear(in_features, num_classes)

Modèle

ResNet34

ResNet50

Best Accuracy

0.915

0.929

Fine-tuning

Une fois de plus le fine tuning s’avère très difficile à mettre en place dans ce contexte.

Le problème principal reste la gestion de la couche FC et du bloc layer4 qui ne démarrent pas du même état.

Plusieurs stratégies peuvent être testées :

Optimiseur à double LR

La librairie PyTorch permet de choisir un learning rate différent pour la tête et le layer4 :

optimizer = torch.optim.Adam([
    {"params": model.model.fc.parameters(), "lr": 1e-3},
    {"params": model.model.layer4.parameters(), "lr": 1e-4},
], lr=1e-4)

Apprentissage en 2 phases

Cette méthode consiste à :

  • d’abord faire apprendre la tête comme dans l’étape de Feature Extraction

  • activer l’apprentissage de layer4, les deux zones apprennent maintenant avec le même LR

Conclusion

L’approche de feature extraction permet d’obtenir rapidement d’excellentes performances. Elle constitue un choix particulièrement intéressant, car elle offre des résultats bien plus rapides et bien plus simples à mettre en œuvre qu’un entraînement from scratch.

En revanche, le fine-tuning reste une étape délicate : les réglages y sont plus sensibles et souvent difficiles à mettre en place.