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