CSC 8607 – Introduction au deep learning

Portail informatique

CI3 : Réseaux convolutifs

Dans ce TP, vous implémenterez différents réseaux de neurones pour résoudre un problème de classification d'images sur le dataset ImageNet. Vous utiliserez PyTorch pour coder les réseaux, et Tensorboard pour visualiser les résultats. Certaines sections comportent des questions théoriques et d'autres des tâches pratiques.

ImageNet est un dataset de taille conséquente. Les entraînements vont donc être longs, mais plus instructifs que sur des datasets plus petits. Pendant le cours, vous implémenterez des réseaux, mais il faudra continuer chez vous à lancer des expériences pour avoir tous les résultats.

Objectifs

  • Charger et prétraiter le dataset ImageNet avec les transformations appropriées.
  • Implémenter une baseline avec un perceptron multicouche (MLP).
  • Développer le modèle AlexNet à partir d'une description détaillée.
  • Construire le modèle ResNet en intégrant des blocs résiduels.
  • Configurer Tensorboard pour enregistrer et visualiser les métriques d'entraînement et de validation, les hyperparamètres, et les performances finales sur l'ensemble de test.
  • Calculer le nombre total de paramètres et la taille en mémoire de chaque modèle.
  • Tester l'impact d'une augmentation de la profondeur des réseaux sur les performances (sous-apprentissage ou sur-apprentissage).
  • Comparer les résultats et les courbes d'entraînement des différents modèles dans Tensorboard.

Chargement du dataset ImageNet

Implémentez un script PyTorch pour charger le dataset en utilisant les classes torchvision.datasets.ImageNet et torch.utils.data.DataLoader. Pour ImageNet, le dossier root est /array/data/imagenet/2012. Notez qu'ImageNet propose déjà un jeu de train et un jeu de validation. Cependant, il n'y a pas de jeu de test publique. On supposera donc pour simplifier que le jeu de validation est le même que le jeu de test. Appliquez les transformations suivantes au dataset :
  • Redimensionnez l'image (resize) pour qu'elle soit carrée et de taille 256 de côté.
  • Découpez une image carrée centrée sur l'image précédente (center crop) de taille 227.
  • Transformez les entrées en tenseurs.
  • Normalisez les données avec les paramètres suivants : mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
Assurez-vous de configurer correctement le DataLoader pour gérer les ensembles d'entraînement et de validation.
Pour ce dataset, lire les données et les transformer prend du temps. Pour accélérer les calculs, on pourra augmenter le nombre de workers en modifiant le paramètre num_workers de DataLoader. Mettez à 16 par exemple.
Lorsque vous exécuter le code, vous allez surement avoir un warning vous disant que 16 est supérieur à la valeur maximale. Il vous faudra donc réserver plus de CPUs dans slurm avec l'argument -c 8 (pour 8 CPUs). Éviter d'en réserver trop pour ne pas bloquer les autres.
data_dir = "/array/data/imagenet/2012" device = torch.device("cuda" if torch.cuda.is_available() else "cpu") batch_size = 256 learning_rate = 0.0001 num_epochs = 100 n_workers = 16 model_name = "mlp" transform_features = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(227), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) print("Loading images...") # Datasets and loaders train_dataset = ImageNet(root=data_dir, split="train", transform=transform_features) print("Train loaded.", len(train_dataset), "examples found.") val_dataset = ImageNet(root=data_dir, split="val", transform=transform_features) print("Val loaded.", len(val_dataset), "examples found.") test_dataset = val_dataset # Using 'val' as ImageNet does not have a separate test set train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=n_workers, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=n_workers, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=n_workers, pin_memory=True) print("Images loaded.")

Que se passe-t-il si vous oubliez de normaliser les données ? Quel est l'impact sur l'entraînement ?
Si les données ne sont pas normalisées, les entrées peuvent avoir des échelles très différentes, ce qui ralentit la convergence des modèles et peut les empêcher d'apprendre correctement. Cela impacte également la stabilité des calculs numériques.

Nous allons maintenant implémenter une stratégie d'augmentation de données pour le jeu d'entraînement. Créez une nouvelle pipeline de transformation pour le training set qui suit les étapes suivantes :
  • Faire un redimensionnement et recadrage aléatoire de taille 227 (RandomResizedCrop)
  • Faire un flip horizontal de manière aléatoire (RandomHorizontalFlip)
  • Transformez les entrées en tenseurs.
  • Normalisez les données avec les paramètres suivants : mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
train_transform_features = transforms.Compose([ transforms.RandomResizedCrop(227), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_dataset = ImageNet(root=data_dir, split="train", transform=train_transform_features)

Implémentation d'une Baseline : Perceptron Multicouche

Implémentez une baseline simple avec un perceptron multicouche (MLP). Écrivez un réseau avec deux couche cachées de taille 256 et une fonction d'activation non linéaire (par exemple, ReLU).
L'entrée est de taille 3x227x227 et la sortie contient 1000 classes.
class MLP(nn.Module): def __init__(self, input_size=3*227*227, hidden_size=256, num_classes=1000): super(MLP, self).__init__() self.classifier = nn.Sequential( nn.Linear(input_size, hidden_size), nn.ReLU(inplace=True), nn.Linear(hidden_size, hidden_size), nn.ReLU(inplace=True), nn.Linear(hidden_size, num_classes), ) def forward(self, x): x = torch.flatten(x, 1) x = self.classifier(x) return x

Combien de paramètre contient votre modèle (on pourra ignorer les biais)
3*227*227*256 + 256*256 + 256*1000 = 39895808

Écrivez une fonction def calculate_loss_and_accuracy(loader, model, criterion) qui évalue le modèle sur un loader. Le loss est donné par le paramètre criterion (on peut faire loss = criterion(outputs, labels)). La fonction retourne le loss total sur les données, ainsi que l'accuracy.
N'oubliez pas de passer en mode eval au début, puis de repasser en mode train à la fin. De plus, n'oubliez pas de ne pas calculer les gradients.
def calculate_loss_and_accuracy(loader, model, criterion): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in tqdm(loader, desc="Evaluation"): images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) correct += (predicted == labels).sum().item() total += labels.size(0) avg_loss = running_loss / total accuracy = correct / total model.train() return avg_loss, accuracy

Écrivez la boucle d'entraînement avec ce modèle. Faites les loggig à chaque fin d'epoch. Enregistrez les métriques de perte, d'accuracy (train et validation), ainsi que les hyperparamètres dans Tensorboard. Vérifiez également l'accuracy finale sur l'ensemble de test. Nous donnons les hyperparamètres suivants (mais vous pouvez en chercher des meilleurs si vous le souhaitez) :
  • batch_size = 256
  • learning_rate = 0.0001
  • num_epochs = 100
  • weight_decay = 1e-5
N'oubliez pas de mettre les données et le modèle sur le GPU !
model = MLP(input_size=3*227*227, hidden_size=256, num_classes=1000).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay) # TensorBoard writer writer = SummaryWriter() # Training loop for epoch in range(num_epochs): print("Running epoch", epoch) model.train() running_loss = 0.0 correct_train = 0 total_train = 0 for i, (images, labels) in enumerate(tqdm(train_loader, desc="Training")): images, labels = images.to(device), labels.to(device) optimizer.zero_grad(set_to_none=True) outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() _, predicted = outputs.max(1) correct_train += (predicted == labels).sum().item() total_train += labels.size(0) train_loss = running_loss / len(train_loader) train_accuracy = correct_train / total_train val_loss, val_accuracy = calculate_loss_and_accuracy(val_loader, model, criterion) writer.add_scalar('Loss/Train', train_loss, epoch) writer.add_scalar('Loss/Validation', val_loss, epoch) writer.add_scalar('Accuracy/Train', train_accuracy, epoch) writer.add_scalar('Accuracy/Validation', val_accuracy, epoch) print( f"Epoch [{epoch + 1}/{num_epochs}], Train Loss: {train_loss:.4f}, Train Accuracy: {train_accuracy:.4f}, Val Loss: {val_loss:.4f}, Val Accuracy: {val_accuracy:.4f}") # Final test loss and accuracy test_loss, test_accuracy = calculate_loss_and_accuracy(test_loader, model, criterion) hparams = { 'batch_size': batch_size, 'learning_rate': learning_rate, 'num_epochs': num_epochs, "model": model_name } metrics = { 'hparam/test_loss': test_loss, 'hparam/test_accuracy': test_accuracy } writer.add_hparams(hparams, metrics) print(f"Test Loss: {test_loss:.4f}, Test Accuracy: {test_accuracy:.4f}") # Save model torch.save(model.state_dict(), f"{model_name}_imagenet.pth") writer.close()

Quel est le principal inconvénient d'utiliser un MLP pour des images, comparé à un réseau convolutif ?
Un MLP traite chaque pixel indépendamment et ne capture pas les relations spatiales dans l'image, contrairement aux réseaux convolutifs qui exploitent la structure locale grâce aux filtres.

Pour économiser de la puissance de calcul et finir les expériences plus rapidement, nous allons mettre en place une stratégie d'early stopping. Pour rappel, cette dernière consiste à arrêter l'entraînement si nous n'observons pas d'amélioration sur le jeu de validation pendant plus de early_stop_patience. Dans ce TP, on choisira early_stop_patience = 5.
early_stop_patience = 5 best_val_loss = float('inf') early_stop_counter = 0 ... print( f"Epoch [{epoch + 1}/{num_epochs}], Train Loss: {train_loss:.4f}, Train Accuracy: {train_accuracy:.4f}, Val Loss: {val_loss:.4f}, Val Accuracy: {val_accuracy:.4f}") # Early stopping check if val_loss < best_val_loss: best_val_loss = val_loss early_stop_counter = 0 torch.save(model.state_dict(), f"best_{model_name}_imagenet.pth") else: early_stop_counter += 1 if early_stop_counter >= early_stop_patience: print("Early stopping triggered. Stopping training.") break # Load the best model model.load_state_dict(torch.load(f"best_{model_name}_imagenet.pth")) # Final test loss and accuracy test_loss, test_accuracy = calculate_loss_and_accuracy(test_loader, model, criterion) hparams = { 'batch_size': batch_size, 'learning_rate': learning_rate, 'num_epochs': num_epochs, "model": model_name } metrics = { 'hparam/test_loss': test_loss, 'hparam/test_accuracy': test_accuracy } writer.add_hparams(hparams, metrics) print(f"Test Loss: {test_loss:.4f}, Test Accuracy: {test_accuracy:.4f}") writer.close()
Vous pouvez maintenant entrainer votre réseau de neurones. Les résultats vous serviront de base de comparaison pour la suite. Suivez vos expériences avec Tensorboard.

Implémentation d'AlexNet

AlexNet est un des premier modèle de deep learning a avoir eu de bons résultats sur ImageNet. Nous proposons de l'implémenter ici.

À partir de la description suivante, implémentez le réseau AlexNet :
Taille entrée
Paramètres couche Taille sortie
Couche C H/W filtres size kernels stride pad C H/W # paramètres
conv 3 227 64 11 4 2 ? ? ?
ReLU ? ? ? ? ?
MaxPool ? ? 3 2 0 ? ? ?
conv ? ? 192
5 1 2 ? ? ?
ReLU ? ? ? ? ?
MaxPool ? ? 3 2 0 ? ? ?
conv ? ? 384 3 1 1 ? ? ?
ReLU ? ? ? ? ?
conv ? ? 256 3 1 1 ? ? ?
ReLU ? ? ? ? ?
conv ? ? 256 3 1 1 ? ? ?
ReLU ? ? ? ? ?
MaxPool ? ? 3 2 0 ? ? ?
flatten ? ? ? ? ?
Dropout ? ? ? ? ?
linear ? ? 4096 ? ? ?
ReLU ? ? ? ? ?
Dropout ? ? ? ? ?
linear ? ? 4096 ? ? ?
ReLU ? ? ? ? ?
Linear ? ? 1000 ? ? ?
class AlexNet(nn.Module): def __init__(self, num_classes=1000): super(AlexNet, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(64, 192, kernel_size=5, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(192, 384, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), ) self.classifier = nn.Sequential( nn.Dropout(), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x

Reprenez le code de l'exercice précédent. Dans ce code, introduisez un paramètre model_name qui permettra de choisir si on instancie un MLP ou un AlexNet.
if model_name == "alexnet": model = AlexNet(num_classes=1000).to(device) elif model_name == "mlp": model = MLP(input_size=3*227*227, hidden_size=256, num_classes=1000).to(device) else: raise ValueError("Invalid model_name. Choose 'alexnet' or 'mlp'.")

Remplissez le tableau de paramètres donné dans une question précédente pour trouver les valeurs de chaque point d'interrogation (?). Combien de paramètres en tout contient notre réseau ? Où se trouvent la majorité des paramètres ?
Taille entrée
Paramètres couche Taille sortie
Couche C H/W filtres size kernels stride pad C H/W # paramètres
conv 3 224 64 11 4 2 64
56 23000
ReLU 64 56 64 56 0
MaxPool 64 56 3 2 0 64 27 0
conv 64 27 192 5 1 2 192 27 307000
ReLU 192 27 192 27 0
MaxPool 192 27 3 2 0 192 13 0
conv 192 13 384 3 1 1 384 13 664000
ReLU 384 13 384 13 0
conv 384 13 256 3 1 1 256 13 885000
ReLU 256 13 256 13 0
conv 256 13 256 3 1 1 256 13 590000
ReLU 256 13 256 13 0
MaxPool 256 13 3 2 0 246 6 0
flatten 246 6 9216 0
Dropout 9216 9216 0
linear 9216 4096 4096 37748736
ReLU 4096 4096 0
Dropout 4096 4096 0
linear 4096 4096 4096 16777216
ReLU 4096 4096 0
Linear 4096 1000 1000 4096000
Le nombre total de paramètres est d'environ 61,091,000. La majorité des paramètres sont dans le MLP à la fin du réseau de neurones.

Vérifiez les résultats de vos calculs en programmant une fonction donnant le nombre total de paramètres dans un modèle.
def calculate_total_parameters(model): """Calculate the total number of parameters in a model.""" return sum(p.numel() for p in model.parameters())

Quand on entraîne un réseau de neurones, il est important de sauvegarder chaque résultat intermédiaire qui sera plus tard utilisé pour la backpropagation. Calculez la taille pour chaque couche la taille de la mémoire nécessaire (i.e. la taille de la mémoire utilisée par la sortie) en KB, en considérant que nous utilisons des floats encodés sur 4 bytes. Calculez la taille totale et comparez la avec la taille total du MLP.
  • conv1 = 64*56*56*4/1024 = 784
  • pool1 = 182
  • conv2 = 547
  • pool2 = 127
  • conv3 = 254
  • conv4 = 169
  • conv5 = 169
  • pool5 = 36
  • flatten = 36
  • linear1 = 16
  • linear2 = 16
  • linear3 = 4
Total = 2340Kb
Total mémoire MLP : 256 * 4 / 1024 + 256 * 4 / 1024 + 1000 * 4 / 1024 = 1+1+4 = 6KB
Bien que le nombre de paramètres soit très similaire, la taille mémoire nécessaire est beaucoup plus grande avec les convolutions.

Vous pouvez maintenant entraîner AlexNet et comparer vos résults avec un MLP.

Modifiez la profondeur d'AlexNet et observez comment les performances changent. Enregistrez les résultats dans Tensorboard (nouvel hyperparamètre : nombre de couches).

Les problèmes observés sont-ils dus à un sur-apprentissage ou à un sous-apprentissage ? Expliquez votre raisonnement.
Si les performances sur l'ensemble d'entraînement sont bonnes mais que celles sur l'ensemble de validation sont mauvaises, c'est du sur-apprentissage. Si les performances sont mauvaises sur les deux ensembles, c'est du sous-apprentissage.
Bien que ça soit contre-intuitif, on peut observer de l'underfitting en augmentant le nombre de couches !