Scénario

Pour organiser notre projet d’IA, nous adoptons un principe simple : la séparation des données et des traitements. Concrètement, toutes les données nécessaires à l’entraînement (bases d’images, réseau, paramètres, etc.) vont être regroupées dans une structure unique appelée Scénario.

Structuration

Pour stocker les données dans le scénario, nous choisissons l’approche suivante :

  • Nous mettons en place une fonction createScenario chargée de retourner un scénario entièrement initialisé

  • Dans cette fonction, nous instantions un objet scenario nommé S

  • Nous ajoutons chaque paramètre au scénario en écrivant : S.myInfo=

class Scenario : pass       # déclaration de la classe scenario

def createScenario():
    S = Scenario()          # instanciation
    S.myData = ...          # ajout d'un paramètre dans le scénario
    ...
    return S

Ainsi, nous pouvons lancer un entraînement en écrivant ces deux lignes :

S = createScenario()   # Données
train(S)               # Traitement

Optimiseur

../_images/schema.png

Les optimiseurs (SGD, Adam…) ont été implémentés à travers des classes accessibles via le module torch.optim. La mise en place d’un optimiseur se fait donc en deux temps :

  • Création du réseau de neurones

  • Instanciation de l’optimiseur en lui fournissant les paramètres du modèle et des paramètres annexes

Adam

L’optimiseur Adam est un optimiseur de choix, car il combine rapidité de convergence et bonne stabilité grâce à une adaptation automatique du taux d’apprentissage. Il représente un choix sûr et efficace, adapté à l’essentiel de nos entraînements. Voici ses paramètres principaux :

params

Ce paramètre permet de transmettre les poids du réseau que l’on récupère grâce à la fonction : model.parameters().

lr - learning rate

Ce paramètre contrôle l’amplitude des mises à jour des poids. On trouve comme valeurs :

  • 1e-2 : rapide mais risqué

  • 1e-3 : valeur par défaut

  • 1e-4 ou 1e-5 : plus stable, mais plus lent

weight_decay

Moins utilisé, vous pourrez le croiser dans certains exemples. Ce paramètre introduit une pénalisation sur la taille des poids afin de limiter le surapprentissage. Valeurs courantes : 0 (désactivé), 1e-4 (léger), 1e-3 à 1e-2 (régularisation plus marquée). La valeur nulle désactive l’effet, trop fort, le modèle apprend… puis oublie.

Voici un exemple :

S.optimizer = torch.optim.Adam(S.model.parameters(), lr = 1e-3)

Scheduler

On présente ici une notion avancée. Le souci avec les optimiseurs classiques est qu’ils utilisent un learning rate constant tout au long de l’entraînement. Un scheduler permet ainsi d’ajuster automatiquement le learning rate durant l’apprentissage. Adam gère le comment apprendre et le scheduler décide quand ralentir.

Bien réglé, le scheduler améliore la convergence et limite les oscillations en fin d’entraînement. Voici un exemple de scheduler qui permet de commencer l’apprentissage avec un learning rate lr_start et de le faire décroître linéairement sur 200 epochs jusqu’à atteindre la valeur lr_end.

lr_start = 1e-3
lr_end   = 1e-5
num_epochs = 200

S.optimizer = torch.optim.Adam(S.model.parameters(), lr=lr_start)
S.scheduler = torch.optim.lr_scheduler.LinearLR(S.optimizer, start_factor=1.0, end_factor=lr_end / lr_start, total_iters=num_epochs)

A chaque fin d’epoch, on appelle l’optimiseur pour modifier les poids du réseau puis le scheduler qui met à jour le learning rate de l’optimiseur :

S.optimizer.step()
S.scheduler.step()

Il existe beaucoup d’autres stratégies de scheduler qui dépassent le cadre de ce cours. On peut citer le Cosine Annealing qui remplace la décroissance linéaire du learning rate par une décroissance calquée sur la fonction cosinus entre 0 et pi/2. Ainsi, sur les premières epochs le learning rate décroit plus lentement et sur les dernières epochs, il est plus rapidement proche de sa valeur finale.

DataLoader

Le DataLoader est une surcouche au-dessus des datasets qui prend en charge la gestion des batchs de données.

Pipeline d’entraînement

Pour le pipeline d’entraînement, la constitution des batchs obéit à deux principes :

  • Les images sont sélectionnées aléatoirement, afin d’éviter de traiter les images toujours dans le même ordre

  • Sur une epoch, chaque image doit être vue exactement une fois

Cette logique a été implémentée dans la classe DataLoader :

S.train_loader = DataLoader(S.trainDS, batch_size=..., shuffle=True)

Pipeline de validation

A-t-on besoin d’un dataloader pour le pipeline de validation ? A première vue, on serait tenté de dire non et on peut effectivement s’en passer sur de petits exemples. Cependant, en production, on utilise un dataloader pour plusieurs raisons :

  • Manque de mémoire : charger toutes les images en une fois peut nécessiter énormément de mémoire

  • Parallélisme : Le dataloader sait paralléliser les traitements sur plusieurs threads : chargement + transform

Pour le pipeline de validation, il n’est cependant pas nécessaire d’activer le shuffle (randomisation) :

S.valid_loader = DataLoader(S.validDS, batch_size=..., shuffle=False)

Quelle taille choisir ?

  • Réduire la taille des batchs rend le gradient instable et amène une courbe de Loss plus bruitée

  • Augmenter la taille des batchs stabilise le gradient et lisse la courbe de Loss

Nous rappelons que la taille des batchs est limitée par la quantité de mémoire disponible.

Taille de batch

Usage recommandé

32 ou 64

Valeurs de référence dans la majorité des cas

128 et plus

Situations particulières, plutôt réservées à des usages expérimentés

Test

Lors du dernier batch, il ne reste généralement pas assez d’images pour faire un batch complet. Effectuons un test pour savoir comment PyTorch gère cette situation :

from torchvision import datasets, transforms
from torch.utils.data import DataLoader

toDo         = transforms.Compose( [ transforms.Resize(128),  transforms.ToTensor() ] )
train_ds     = datasets.OxfordIIITPet( root="data", split="trainval", download=True, transform = toDo )
train_loader = DataLoader(train_ds, batch_size=1024, shuffle=True)


for x, y in train_loader:
   print(f"Batch shape : {x.size()}  --- {y.size()}")


>> Batch shape : torch.Size([1024, 3, 128, 128])  --- torch.Size([1024])
>> Batch shape : torch.Size([1024, 3, 128, 128])  --- torch.Size([1024])
>> Batch shape : torch.Size([1024, 3, 128, 128])  --- torch.Size([1024])
>> Batch shape : torch.Size([608, 3, 128, 128])   --- torch.Size([608])

Cet exemple montre que PyTorch crée un dernier batch de taille non-standard. Ainsi avec un jeu de données contenant 3680 images, le dataLoader génère 3 batchs de 1024 images et un dernier batch avec les 608 images restantes.

Scénarios paramétrés

On peut ajouter des paramètres à la fonction createScenario() pour tester différentes valeurs d’un hyperparamètre :

def createScenario(lr):
   ...

scenar1 = createScenario(lr=1e-3)
scenar2 = createScenario(lr=3e-4)
scenar3 = createScenario(lr=1e-4)

Fonction d’erreur

On stocke la fonction d’erreur choisie pour l’entraînement à l’intérieur du scénario :

S.loss_fn   = nn.CrossEntropyLoss()

Note

L’écriture nn.CrossEntropyLoss() correspond à l’instanciation de la classe CrossEntropyLoss et non à un appel de fonction qui calcule la cross entropy loss ! Lorsque PyTorch utilise cet objet pour calculer la Loss finale, il appelera, comme pour les autres couches, la fonction forward de cet objet.

GPU

On peut utiliser une variable pour indiquer si l’entraînement a lieu sur GPU ou sur CPU :

S.device = "cuda"
S.device = "cpu"

Si vous voulez que ce paramètre active le GPU de manière automatique :

S.device    = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Tensorboard

Afin d’obtenir des affichages interactifs de nos entraînements, nous allons sauvegarder régulièrement des logs de notre progression. Pour cela, nous stockons dans le scénario le répertoire de destination de ces logs :

S.log_folder = r"C:\log\test1"

Scénarios

Pour chaque run, créez un scénario contenant tous les paramètres nécessaires. Si vous faites plusieurs runs, ne recyclez pas un scénario précédent, construisez en un nouveau.

Création

Nous mettons en place la fonction createScenario() qui va retourner un objet scenario entièrement configuré. Par la suite, il est possible d’ajouter des paramètres à cette fonction pour personnaliser les différents scénarios d’apprentissage.

Astuce

Snippet de code pour la création complète d’un scénario

import torch
from torch.utils.data import DataLoader

...

train_ds, val_ds = LoadDS(dataset, T_train, T_validation)

class Scenario : pass

def createScenario(CSVname = None):

    S = Scenario()
    S.log_folder = r"C:\log\test1"
    S.CSVname    = CSVname

    # train/val
    S.epochs = 10
    S.batch_size = 32
    S.train_batch = DataLoader(train_ds, batch_size=S.batch_size, shuffle=True)
    S.valid_batch = DataLoader(val_ds,   batch_size=S.batch_size, shuffle=False)


    # model / optimizer
    S.device    = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    S.model     = SimpleCNN()
    S.loss_fn   = nn.CrossEntropyLoss()
    S.optimizer = torch.optim.Adam(S.model.parameters(), lr=1e-3)

    return S

scenar1 = createScenario()

On a ainsi découplé la préparation des données de la phase d’apprentissage.