Applications

Nous mettons en pratique la méthode Training from scratch.

Le réseau

Présentation

L’objectif est de proposer un réseau simple, entraînable from scratch, adapté à des images de petite taille. L’architecture que nous allons proposer s’inscrit dans la lignée des premiers réseaux convolutionnels, tels que LeNet ou les architectures de type VGG, fondés sur l’empilement progressif de couches Conv2d. À chaque bloc, la résolution spatiale est divisée par deux, tandis que le nombre de features augmente :

class CifarCNNBase(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()

        # L -> L/2 -> L/4 -> L/8
        self.features = nn.Sequential(
            ConvBlock(3, 64),     #  64 x L/2 x L/2
            ConvBlock(64, 128),   # 128 x L/4 x L/4
            ConvBlock(128, 256),  # 256 x L/8 x L/8
        )

        self.classifier = nn.Sequential(
            nn.AdaptiveAvgPool2d((1, 1)),  # 256 x 1 x 1
            nn.Flatten(),                  # 256
            nn.Linear(256, num_classes)    # 10
        )

    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

La couche AdaptiveAvgPool2d((1,1)) permet de ramener les deux dernières dimension à (1,1). Ainsi, quelle que soit la résolution en entrée du classifier, Flatten produit toujours un tenseur de 256 valeurs.

Voici comment structurer un ConvBlock :

class ConvBlock(nn.Sequential):
    def __init__(self, c_in, c_out):
        super().__init__(
            nn.Conv2d(c_in, c_out, 3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2)
        )

Supposons que nous avons un tenseur en entrée N images : [N,3,32,32], le padding fixé à 1, augmente la résolution à 34, ce qui après l’application de la convolution 2D amène à la résolution spatiale de départ : 32x32. C’est la couche MaxPool2d qui va réduire la résolution spatiale par 2. Le nombre de canaux n’intervient pas dans ce calcul.

Test

Effectuons un test pour voir si tout fonctionne correctement :

model = CifarCNNBase(num_classes=10)

x = torch.randn(8, 3, 32, 32)
print(  model(x).shape )

x = torch.randn(8, 3, 256, 256)
print(  model(x).shape )

>> torch.Size([8, 10])
>> torch.Size([8, 10])

Classifier GAP

Si l’on traite, par exemple, des images de taille 64×64, on obtient en sortie de l’extracteur de features un tenseur de dimensions 256×8×8. Dans les architectures CNN historiques (années 1990–2010), on appliquait ensuite une opération de Flatten, suivie d’une couche entièrement connectée. On injectait alors 16 384 valeurs en entrée de la couche FC, ce qui conduisait à un très grand nombre de paramètres dans le classifieur, constituant une source importante de surapprentissage.

Entre 2010 et 2015, une période transitoire a vu l’utilisation de classifieurs basés sur deux couches entièrement connectées empilées (MLP bicouche). Cette approche permettait d’augmenter la capacité d’apprentissage du modèle grâce à une plus grande non-linéarité, mais le problème du surapprentissage n’était pas résolu, car l’opération de Flatten était toujours utilisée et le nombre de neurones — et donc de paramètres — restait très élevé. Pour compenser, il était alors nécessaire de disposer de jeux de données beaucoup plus volumineux, ainsi que de techniques de régularisation telles que le dropout.

À partir de 2015, une autre approche s’est imposée pour contourner ce problème : le Global Average Pooling (GAP). Cette technique consiste à sélectionner pour chaque feature du la dernière couche Conv une seule valeur. La couche AdaptiveAvgPool2d permet ainsi d’obtenir un vecteur de taille fixe (ici 256), indépendamment de la résolution des images en entrée. Cette approche limite fortement le surapprentissage en réduisant drastiquement la quantité d’informations injectées dans la couche de classification.

CIFAR10

Configuration : petite résolution d’images, donc des temps d’apprentissage réduit. Un grande quantité d’images par classe (4000) ce qui limite les problèmes d’overfitting.

Voici l’architecture choisie pour démarrer : 3 CNN imbriqués + GAP.

self.features = nn.Sequential(
    ConvBlock(3, 64),
    ConvBlock(64, 128),
    ConvBlock(128, 256),
)

self.classifier = nn.Sequential(
    nn.AdaptiveAvgPool2d((1, 1)),  # 256 x 1 x 1
    nn.Flatten(),                  # 256
    nn.Linear(256, num_classes)    # 10
)

Vous pouvez télécharger le code de l'exemple

Phase 1 - Setup

  • Les données

    Nous mettons en place la normalisation du dataset pour le train et la validation :

    mean= (0.4914, 0.4822, 0.4465)
    std = (0.2023, 0.1994, 0.2010)
    T_train = transforms.Compose([ transforms.ToTensor(),   transforms.Normalize(mean=mean,std=std) ])
    T_valid = transforms.Compose([ transforms.ToTensor(),   transforms.Normalize(mean=mean,std=std) ])
    

    La base CIFAR10 disposant d’un grand nombre d’images par classe (4000), nous n’activons pas la data augmentation pour l’instant.

  • Choisir un optimiseur

    Nous choisissons Adam.

Phase 2 - Optimisation du learning rate

Exploration initiale

Pour Adam, l’exploration initiale est : 3e-4, 1e-3, 3e-3. Nous avons donc comme boucle principale :

L = [ 3e-4, 1e-3, 3e-3 ]
for lr in L :
    S = createScenario(lr = lr)
    train(S,"__" + str(lr))

Pour ce premier run, nous avons choisi 70 epochs, ce qui est volontairement élevé. L’objectif était de montrer qu’au-delà d’un certain seuil, l’apprentissage stagne. Pour vos propres tests, vous pouvez bien entendu réduire ce nombre.

Voici les graphiques obtenus. Cet affichage est interactif, si vous passez la souris dessus, vous verez les valeurs s’afficher.

Interprétations des courbes

  • Pas de cas éliminatoire : overshoot, underfitting, undertraining

  • Critères principaux : nous avons trois courbes de loss/train lisses qui décroissent de manière réguliere. Sur les 10/15 premières epochs, la décroissance des trois courbes est similaire.

  • Critère ignoré : absence d’overfitting

  • Critères annexes : finalement, c’est un critère secondaire qui va nous permettre de prendre une décision :

    • Best accuracy/validation : la courbe du LR de 3e-3 ne dépasse pas 70% ce qui est peu par rapport aux deux autres. La courbe du LR 3e-4 atteint 77% et celle du LR 1e-3 75%.

Nous retenons par rapport à ce choix de critère annexe la valeur 3e-4 comme learning rate.

Affinage

Nous allons explorer des valeurs de LR proches de 3e-4. Par rapport aux simulations précédentes, on n’étudiera que 50 epochs ce qui est largement suffisant.

Voici le code de principe :

L = [ 1e-4, 2e-4, 3e-4, 5e-4, 8e-4]
for lr in L :
    S = createScenario(lr = lr)
    train(S,"__" + str(lr))

Voici les graphiques obtenus :

  • Cas éliminatoire : idem aucun

  • Critères principaux : même situation : les courbes loss/train décroissent de manière régulire, rien à redire

  • Critère ignoré : absence d’overfitting

  • Critères annexes : Meilleure Accuracy/train, vitesse de convergence raisonnable, moins d’overconfidence, moins d’overfit…

    • LR = 1e-4 Cette valeur conduit à une convergence lente. La diminution de la loss de validation est limitée et l’accuracy de validation reste inférieure à celle obtenue avec des learning rates plus élevés

    • LR = 5e-4 et 8e-4 Ces valeurs permettent une convergence rapide. Cependant le gap d’accuracy entre train et val est très important, ces valeurs conduisent à une mauvaise généralisation.

    • Si l’on zoome et que l’on regarde l’accuracy sur les 20 premières epochs, on s’aperçoit que le gap de la courbe rouge et de la verte reste faible. Cela dénote un scénario où l’overfit est minimal et la généralisation maximal.

On retient donc les valeurs 2e-4 qui cumule convergence rapide et bonne généralisation.

Phase 3 : Amélioration du modèle

  • Optimiser l’architecture

    Nous allons voir si faire varier le nombre de features peut apporter quelque chose. Nous rappelons qu’à ce niveau nous ne gérons pas l’overfit. Nous testons 2 réseaux avec moins de features et 2 autres avec plus de features : Nous testons : 32/64/128 - 48/96/192 - 64/128/256 - 96/192/384 - 128/256/512

To be continued…