DataSet

Torchvision

Contexte

PyTorch est une librairie de calcul tensoriel (autograd, layer, optimiseurs…), elle reste une librairie généraliste et n’est pas spécifiquement orientée vers une thématique particulière comme l’images, le texte, l’audio ou la finance…

La librairie torchvision est une librairie dédiée à la vision par ordinateur. Elle repose sur PyTorch, mais ajoute tout ce qui est spécifique aux images :

  • des datasets standards (MNIST, CIFAR, Oxford Pets, etc.)

  • des transformations : redimensionnement, normalisation, data augmentation

  • des modèles pré-entraînés (ResNet, VGG, MobileNet, etc)

  • des outils d’entrée/sortie pour les images (via PIL)

Datasets

Le module datasets de la librairie torchvision contient de nombreux jeux de données. Chaque jeu de données fournit sa propre fonction d’accès décrite dans la documentation . Nous nous intéressons à Oxford-IIIT Pets qui propose 37 classes en couleur de chats et de chiens.

import torch
from torchvision import datasets

train = datasets.OxfordIIITPet(root="data", split="trainval",  download=True)

print(len(train))            # 3680
image, label = train[0]      # accès à la première image du set
print(type(image))           # PIL.Image

Les bases d’images provenant de torchvision.Datasets fournissent tous les mêmes facilités :

  • Une fonction len qui indique le nombre d’images

  • L’indexation : la syntaxe train[0] retourne une image avec son label

  • L’image retournée est de type PIL.Image, PIL étant une librairie Python très répandue pour la manipulation d’images

Ainsi, l’objet train sert d’interface entre les données brutes stockées sur disque et l’algorithme d’apprentissage. Pour information :

print(image.size)            # (394, 500)
print(image.mode)            # RGB
print(type(label))           # int

Les images disponibles ont une résolution bien plus élevée que celles de MNIST (28×28), elles sont en couleur (trois canaux RGB) et leurs labels sont représentés sous la forme d’entiers.

Note

Pourquoi partir de données raw, les convertir en PIL.Image, puis les reconvertir ensuite en données raw pour les injecter dans le réseau ? Ce détour permet d’utiliser la librairie PIL afin d’appliquer des transformations (rotation, recadrage, miroir, etc.) sur les images. En ajoutant des variations aléatoires, on peut ainsi réaliser de la data augmentation (voir chap. avancé).

Exemples

On ne peut pas s’en priver, voici quelques images choisies au hasard.

import random
import matplotlib.pyplot as plt

def randomShow(DS,classNames):

    nx,ny = 7,4
    indices = random.sample(range(len(DS)), nx*ny)

    plt.figure(figsize=(2*nx, 2*ny))
    for i, idx in enumerate(indices):
        img, label = DS[idx]
        plt.subplot(ny, nx, i + 1)
        plt.imshow(img)
        plt.title(classes[label], fontsize=12)
        plt.axis("off")

    plt.tight_layout()
    plt.show()

classes = ["Abyssinian","american_bulldog","american_pit_bull_terrier","basset_hound","beagle","Bengal","Birman","Bombay",
"boxer","British_Shorthair","chihuahua","Egyptian_Mau","english_cocker_spaniel","english_setter","german_shorthaired","great_pyrenees","havanese",
"japanese_chin","keeshond","leonberger","Maine_Coon","miniature_pinscher","newfoundland","Persian","pomeranian","pug","Ragdoll","Russian_Blue",
"saint_bernard","samoyed","scottish_terrier","shiba_inu","Siamese","Sphynx","staffordshire_bull_terrier","wheaten_terrier","yorkshire_terrier"]



import torch
from torchvision import datasets

train = datasets.OxfordIIITPet(root="data", split="trainval",  download=True)

randomShow(train,classes)

On remarque que les noms des classes ne sont pas fournis, il faut donc les donner explicitement. Par ailleurs, les images présentent des formats variés : certaines sont en mode paysage, d’autres en mode portrait.

../_images/img.jpg

Pipeline de validation

Les modèles nécessitant des entrées de taille fixe, nous devons appliquer des transformations aux images pour obtenir une résolution finale de 128×128 pixels. Pour le set de validation, les transformations suivantes sont appliquées :

Resize + CenterCrop

Cette approche consiste à redimensionner l’image telle que la longueur du petit coté corresponde à la taille désirée. L’image étant rectangulaire, elle est ensuite cropée (découpée) en son centre pour obtenir un carré, les bords étant perdus. L’avantage de cette approche est qu’elle maintient les proportions et qu’une grande partie du sujet est conservée.

import torch
from torchvision import datasets, transforms

s = 128
T = transforms.Compose([ transforms.Resize(s), transforms.CenterCrop(s) ])
train = datasets.OxfordIIITPet(root="data",   split="trainval", download=True,  transform= T )
randomShow(train,classes)
../_images/resize&crop.png

Vous remarquerez que, sur certaines images, des sujets ont le haut de la tête tronqué ou la queue coupée.

Note

Vous pourrez croiser une approche plus basique qui consiste à étirer l’image originale vers un format carré : transforms.Resize((128, 128)). Cette approche, qui modifie les proportions, reste possible dans certains cas :

  • prototypage rapide

  • besoin de rapidité

  • images déjà carrées ou de même ratio

Cette approche est aujourd’hui moins utilisée en classification moderne.

ToTensor

Les données correspondant à des images PIL, il faut maintenant les convertir en tenseur PyTorch avec la transformation :

transforms.ToTensor()

Les images PIL étant 8 bits, ToTensor applique une division par 255, pour ramener les valeurs dans l’intervalle [0,1].

Examions le résultat :

from torchvision import datasets, transforms
toDo = transforms.Compose( [ transforms.Resize(128), transforms.CenterCrop(128),  transforms.ToTensor() ] )
train = datasets.OxfordIIITPet( root="data", split="trainval", download=True, transform = toDo )

data,cat = train[0]
print(type(data))    #  <class 'torch.Tensor'>
print(data.shape)    #  torch.Size([3, 128, 128])

Normalisation

Principe

Prenons l’exemple d’une image de faune marine, on obtient typiquement un histogramme de cette forme :

../_images/histo.png

Sur cet histogramme, les rouges sont dans la zone 0% à 20% ce qui est normal car ils sont peu présents. Les bleus sont évidemment très présents et lumineux, ils sont donc placés dans les hautes valeurs entre 70% et 100%. La présence de vert dans les hautes valeurs peut surprendre, mais la couleur du fond marin tire sur un bleu cyan RGB(38, 196, 236) ce qui correspond au profil de l’histogramme ci-dessus.

La normalisation consiste à ramener les valeurs des données sur une échelle commune, généralement en centrant chaque canal R,V,B autour de zéro et en ajustant son écart-type à 1. Cette préparation permet au réseau d’apprendre plus efficacement, sans perdre de temps à compenser des échelles mal adaptées.

Calcul

Pour pouvoir appliquer cette normalisation, il faut connaître la couleur moyenne des images du dataset. Soit vous utilisez un dataset officiel et vous pourrez trouver ces valeurs sur le net, soit il faudra le calculer vous-mêmes. Pour éviter de donner plus de poids aux images haute-résolution, il faut effectuer cette estimation sur les images fournies par la DataLoader standard (images au format carré).

Avertissement

Il est recommandé de faire ce calcul une seule fois car il prend du temps. Ensuite, les valeurs trouvées sont recopiées en dur dans le programme.

import torch
from torchvision import datasets, transforms

# Dataset
transform = transforms.Compose([transforms.Resize(128), transforms.CenterCrop(128), transforms.ToTensor()])
ds = datasets.OxfordIIITPet(root="data",split="trainval",download=True,transform=transform)

# Calcul du mean et du std par canal
mean = torch.zeros(3)
std  = torch.zeros(3)
for img, _ in ds:
    img   = img.view(3, -1)   # (3, 128, 128) => (3, 16384)
    mean += img.mean(dim=1)
    std  += img.std(dim=1)    # approx : moyenne des std

n = len(ds)
print("Mean :", mean / n)
print("Std  :", std / n)

>> Mean : tensor([0.4827, 0.4472, 0.3974])
>> Std  : tensor([0.2238, 0.2210, 0.2222])

Les puristes remarqueront que nous avons pris une moyenne des écarts-types, ce qui constitue une bonne estimation de l’écart-type global.

Conclusion

Toute image du set de validation injectée dans le réseau, doit suivre le pipeline suivant :

Resize + crop : format carré

ToTensor : Image PIL => tenseur

Normalize : recentre les valeurs

Ainsi, nous avons :

Astuce

Snippet de code pour les transformations du pipeline de validation

mean = [0.4827, 0.4472, 0.3974]
std  = [0.2238, 0.2210, 0.2222]

T_validation = transforms.Compose([transforms.Resize(128),
                                   transforms.CenterCrop(128),
                                   transforms.ToTensor(),
                                   transforms.Normalize(mean=mean,std=std) ])

Pipeline de train : Data Augmentation

Pour l’apprentissage, la data augmentation est utilisée pour enrichir artificiellement un jeu de données en appliquant des transformations aux images existantes (rotations, translations, bruit, etc.), notamment lorsque le nombre d’échantillons disponibles est limité. Elle permet ainsi de réduire le surapprentissage et d’améliorer la capacité de généralisation du modèle sans avoir à collecter de nouvelles données. Cette technique est appliquée uniquement sur le set de train, pas sur celui de validation.

Redimensionnement

Resize + pad

Cette approche consiste à réduire l’image afin que son plus grand côté corresponde à la longueur désirée. L’espace manquant pour obtenir une image carrée est ajouté en créant des bords noirs (padding). Cette technique n’est généralement pas utilisée en classification mais très présente en détection d’objets (YOLO), en OCR, ou encore dans le médical ; car elle évite de couper les bounding boxes ou le texte. Nous la présentons ici à titre de culture générale.

../_images/resize&pad.png

code exemple

RandomResizedCrop

Cette approche consiste à choisir aléatoirement une zone dans l’image et à la redimensionner vers la taille voulue. Il est possible de préciser 2 paramètres :

  • scale : spécifie le % min/max de la surface de l’échantillon relativement à la surface de l’image originale

  • ratio : spécifie le ratio min/max pour le ratio H/L de l’échantillon

code exemple

Pour bien comprendre cette approche, nous l’appliquons plusieurs fois à la même image :

../_images/randomresizedcrop.png

Cette approche est LA transformation d’entraînement de ResNet, VGG, EfficientNet, MobileNet, ConvNeXt. L’objectif est d’apprendre à reconnaître un objet, même partiellement visible, ceci à différentes échelles.

HorizontalFlip

Cette approche consiste à faire une symétrie gauche/droite de l’image avec une probabilité d’une chance sur deux. Voici la syntaxe utilisée :

transforms.RandomHorizontalFlip(p=0.5)

Dans Oxford Pets III, on classe des chiens et des chats photographiés dans des conditions réelles : debout, assis, couchés… mais toujours “à l’endroit”. Ainsi, on ne cherche pas à appliquer une symétrique verticale car cela créerait des images qui n’existent pas dans le monde réel.

RandomRotation

Il peut être utile de faire légèrement tourner l’image pour introduire un peu de variation en utilisant la transformation :

transforms.RandomRotation(10, interpolation=transforms.InterpolationMode.BICUBIC)

Le paramètre donné correspond à l’angle maximum exprimé en degrés.

code exemple

Voici quelques exemples :

../_images/rot.png

ColorJitter

ColorJitter améliore la robustesse du modèle en modifiant aléatoirement la luminosité, le contraste, la saturation et la teinte afin de simuler des conditions d’éclairage variées. Voici ses paramètres :

  • brightness : variation aléatoire de la luminosité, facteur multiplicatif autour de 1 (ex. 0.2 → luminosité dans [0.8, 1.2])

  • contrast : variation aléatoire du contraste, idem

  • saturation : variation aléatoire de l’intensité des couleurs, idem

  • hue : léger décalage aléatoire de la teinte (couleur dominante), valeur dans [−0.5,0.5] correspondant à une fraction du cercle des couleurs

transforms.ColorJitter(brightness=0, contrast=0, saturation=0, hue=0)

code exemple

Voici quelques exemples :

../_images/jitter.png

Avertissement

Attention aux valeurs données, si elles sont grandes, les images générées peuvent devenir psychédéliques !

../_images/jitter2.png

Conclusion

Astuce

Snippet de code pour les transformations du pipeline de validation avec Data Augmentation

mean = [0.4827, 0.4472, 0.3974]
std  = [0.2238, 0.2210, 0.2222]

T_train = transforms.Compose([
    transforms.RandomResizedCrop(128, scale=(0.8, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(10),
    transforms.ColorJitter(0.1, 0.1, 0.1, 0.05),
    transforms.ToTensor(),
    transforms.Normalize(mean=mean, std=std),
])

Datasets : Train & Valid

Pour rappel, on doit mettre en place deux datasets distincts pour l’entraînement :

  • Un dataset de train : utilisée pour faire évoluer les poids du réseau

  • Un dataset de validation : utilisée uniquement pour évaluer les performances du réseau sur des données qu’il n’a jamais utilisées pour calculer son gradient.

../_images/bases.png

La base d’images OxfordIIITPet met à disposition un seul lot d’images pour le train et le valid. Il faut donc mettre en place une astuce pour gérer deux pipelines distincts construits sur un même lot d’images.

L’astuce consiste à :

  • Créer une liste d’index pour l’ensemble des images

  • Créer une partition de ces index ce qui équivaut à créer une partition train/valid des images

  • Créer une classe dérivée de la classe Dataset pour gérer chaque partition

Ainsi, chaque image va apparaître une seule fois : soit dans le set de train, soit dans le set de validation.

class SubDataset(torch.utils.data.Dataset):

    def __init__(self, base_dataset, indices, transform=None):
        self.base_dataset = base_dataset
        self.indices      = indices
        self.transform    = transform

    def __len__(self):
        return len(self.indices)

    def __getitem__(self, i):
        x, y = self.base_dataset[self.indices[i]]
        if self.transform:
            x = self.transform(x)
        return x, y

Note

Avec cette méthode le jeu de données d’origine est chargée une seule fois en mémoire.

Code

Voici la fonction générique permettant de charger les datasets PyTorch que nous utilisons par la suite.

Astuce

Snippet de code pour la création des datasets de train et de valid

import torch
from torchvision import datasets, transforms
from torch.utils.data import random_split
from torch.utils.data import Subset


class SubDataset(torch.utils.data.Dataset):
    def __init__(self, base_dataset, indices, transform=None):
        self.base_dataset = base_dataset
        self.indices      = indices
        self.transform    = transform

    def __len__(self):
        return len(self.indices)

    def __getitem__(self, i):
        x, y = self.base_dataset[self.indices[i]]
        if self.transform:
            x = self.transform(x)
        return x, y

def LoadDS(dataset, transform_train, transform_valid, split=0.8) :

    nb = len(dataset)
    n_train = int(split * nb)

    g = torch.Generator().manual_seed(0)
    idx = torch.randperm(nb, generator=g).tolist()
    train_idx = idx[:n_train]
    val_idx   = idx[n_train:]

    train_ds = SubDataset(dataset, train_idx, transform_train)  # 80% des images
    val_ds   = SubDataset(dataset, val_idx,   transform_valid)  # 20% des images

    print("Dataset LOADED")
    return train_ds, val_ds

Pour des raisons pédagogiques, nous fixons le seed à 0 pour obtenir la même partition d’un run à l’autre. Ainsi vous obtiendrez des résultats similaires entre deux sessions.

Test

Vous pouvez charger la pluspart des datasets du cours en lançant le code suivant :

T = transforms.Compose([transforms.ToTensor() ])

DS = [ datasets.MNIST,             #  11 Mo
        datasets.FashionMNIST,      #  29 Mo
        datasets.EuroSAT,           #  92 Mo
        datasets.CIFAR10,           # 177 Mo
        datasets.CIFAR100,          # 181 Mo
        datasets.OxfordIIITPet      # 774 Mo
        ]

for current_dataset in DS :
    dataset = current_dataset("\.data",download=True, transform=None)
    train_ds, val_ds = LoadDS(dataset,T,T)
    print(f"{current_dataset.__name__:<16}{len(train_ds):>8}{len(val_ds):>8}")

>> MNIST              48000   12000
>> FashionMNIST       48000   12000
>> EuroSAT            21600    5400
>> CIFAR10            40000   10000
>> CIFAR100           40000   10000
>> OxfordIIITPet       2944     736

Base d’images

Si vous avez un répertoire avec tous vos fichiers images, il est possible de créer une classe dataset à partir de ces fichiers grâce à la fonction suivante :

Astuce

Snippet de code pour la création d’un dataset relatif à un répertoire d’images

from pathlib import Path
from PIL import Image
from torch.utils.data import Dataset
from torchvision import transforms

class ImageOnlyDataset(Dataset):
    def __init__(self, root, transform=None):
        self.files = sorted(Path(root).glob("*.jpg"))
        self.transform = transform

    def __len__(self):
        return len(self.files)

    def __getitem__(self, idx):
        img = Image.open(self.files[idx]).convert("RGB")
        if self.transform:
            img = self.transform(img)
        return img

Dataset : Test

Dans les librairies Torchvision, il existe aussi une base de Test qui permet de comparer différents réseaux pour un concours. Elle permet ainsi d’évaluer les réseaux sur cette base constituée d’images jamais utilisées dans le processus d’apprentissage.

Pourquoi cela ? En fait, lorsque l’on entraîne plusieurs fois le réseau, on examine ses performances sur la base de validation. Le problème est que l’on finit par sur-tester car indirectement, on modifie à chaque fois les hyperparamètres afin d’améliorer l’accuracy sur la base de validation. Ce geste n’est pas anodin et il représente un surapprentissage manuel.

La base de test permet donc une vérification finale, sur des données que ni le modèle ni les développeurs n’ont utilisées, afin d’estimer la performance réelle des réseaux.

L’utilisation de cette base est HORS PROGRAMME.