Développement d'un Classifieur d'Images Médicales pour le Virus du Mpox avec PyTorch

Objectifs du Projet

L'objectif principle de cette implémentation est de construire un modèle d'apprentissage profond capable de discriminer automatiquement les lésions cutanées liées au virus du mpox (anciennement connu sous le nom de variole du singe) par rapport aux autres affections. Le processus vise à atteindre une précision sur l'ensemble de test supérieure à 88 %, idéalement proche de 90 %.

  • Sauvegarde automatique des poids optimaux durant l'entraînement.
  • Capacité d'inference locale après chargement du meilleur checkpoint.
  • Optimisation hyperparamétrique via Dropout et ajustement du taux d'apprentissage.

Configuration de l'Environnement

Nous utilisons ici la bibliothèque PyTorch pour orchestrer le calcul. La première étape cnosiste à vérifier la disponibilité du matériel GPU pour accélérer les calculs matirciels.

import torch
import torch.nn as nn
import torchvision.transforms as transforms
from sklearn.model_selection import KFold
import pathlib

# Vérification du dispositif de calcul
dev_dispo = "cuda" if torch.cuda.is_available() else "cpu"
device = torch.device(dev_dispo)

print(f"Materiel utilisé : {device}")

Préparation des Données

Pour la gestion des chemins d'accès fichiers, nous exploitons le module pathlib, qui standardise la manipulation des répertoires quel que soit le système d'exploitation (Windows ou Linux). Les données brutes sont organisées par classe dans des sous-dossiers.

base_donnees = pathlib.Path('../data/mpox_dataset')
chemins_fichiers = list(base_donnees.glob('*'))

# Extraction des noms de classes
list_classes = [str(p).split("\\")[-1] for p in chemins_fichiers]
print(list_classes)
# ['Autres', 'Mpox']

Les transformations nécessaires incluent le redimensionnement des images vers une résolution standardisée de 224x224 pixels, la conversion en tenseur PyTorch, ainsi qu'une normalisation selon les statistiques globales (ImageNet).

transformations_entrainement = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

jeu_de_donnees = datasets.ImageFolder(root=base_donnees, transform=transformations_entrainement)
print(jeu_de_donnees.class_to_idx)
# {'Autres': 0, 'Mpox': 1}

Répartition Train/Test

Nous séparons les échantillons en deux parties distinctes : 80 % pour l'entraînement et 20 % pour l'évaluation finale.

taille_train = int(0.8 * len(jeu_de_donnees))
taille_test = len(jeu_de_donnees) - taille_train
train_split, test_split = torch.utils.data.random_split(jeu_de_donnees, [taille_train, taille_test])

batch_size_calcul = 32
dataloader_train = torch.utils.data.DataLoader(train_split, batch_size=batch_size_calcul, shuffle=True)
dataloader_test = torch.utils.data.DataLoader(test_split, batch_size=batch_size_calcul, shuffle=True)

Architecture du Réseau de Neurones

Le modèle choisi est un réseau de neurones convolutif (CNN) enrichi avec des couches de normalisation par lot (Batch Normalization) pour stabiliser l'apprentissage. Nous intégrons également des couches de régularisation (Dropout) pour réduire le surapprentissage.

class Module_Mpox(nn.Module):
    def __init__(self):
        super(Module_Mpox, self).__init__()
        
        # Bloc de caractéristiques 1
        self.extraction1 = nn.Sequential(
            nn.Conv2d(3, 12, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(12),
            nn.ReLU(inplace=True),
            nn.Conv2d(12, 12, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(12),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # Bloc de caractéristiques 2
        self.extraction2 = nn.Sequential(
            nn.Conv2d(12, 24, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(24),
            nn.ReLU(inplace=True),
            nn.Conv2d(24, 24, kernel_size=5, stride=1, padding=0),
            nn.BatchNorm2d(24),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # Couches fully connected
        self.regularisation = nn.Dropout(p=0.2)
        # Calcul dynamique de la dimension d'entrée pour éviter les erreurs statiques
        self.fc_in_dim = None 
        self.classif = nn.Sequential(
            nn.Linear(self.fc_in_dim, 120),
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.1),
            nn.Linear(120, len(list_classes))
        )

    def forward(self, x):
        x = self.extraction1(x)
        x = self.extraction2(x)
        
        # Aplatir dynamiquement les dimensions spatiales
        x = x.view(x.size(0), -1) 
        
        # Initialiser la taille d'entrée si elle n'est pas définie
        if self.fc_in_dim is None:
             self.fc_in_dim = x.shape[1]
             self.classif[0] = nn.Linear(x.shape[1], 120)
             self.classif[4] = nn.Linear(120, len(list_classes))
             
        x = self.regularisation(x)
        return self.classif(x)

model_instance = Module_Mpox().to(device)

Procédure d'Entraînement

L'algorithme d'optimisation sélectionné est la Descente de Gradient Stochastique (SGD). La fonction de perte correspond à l'entropie croisée catégorielle.

function_perd = nn.CrossEntropyLoss()
learning_lr = 0.0005
optimisateur = torch.optim.SGD(model_instance.parameters(), lr=learning_lr)

La boucle principale sépare les étapes de mise à jour des gradients et d'évaluation. La gestion explicite des états train et eval est cruciale pour activer/désactiver correctement le Dropout.

def executer_entrainement(data_loader, modele, perte, optim):
    mode = modele.train()
    erreur_cumulee = 0.0
    precision_cumulee = 0.0
    total_exemples = len(data_loader.dataset)
    
    for img, label in data_loader:
        img, label = img.to(device), label.to(device)
        
        pred_logit = modele(img)
        val_perte = perte(pred_logit, label)
        
        optim.zero_grad()
        val_perte.backward()
        optim.step()
        
        erreur_cumulee += val_perte.item()
        _, pred_max = torch.max(pred_logit, 1)
        precision_cumulee += (pred_max == label).sum().item()
        
    prec_final = precision_cumulee / total_exemples
    perte_moy = erreur_cumulee / len(data_loader)
    return prec_final, perte_moy

def verifier_performance(data_loader, modele, perte):
    modele.eval()
    avec torch.no_grad():
        erreur_cumulee = 0.0
        precision_cumulee = 0.0
        total_exemples = len(data_loader.dataset)
        
        for img, label in data_loader:
            img, label = img.to(device), label.to(device)
            pred_logit = modele(img)
            val_perte = perte(pred_logit, label)
            
            erreur_cumulee += val_perte.item()
            _, pred_max = torch.max(pred_logit, 1)
            precision_cumulee += (pred_max == label).sum().item()
            
    return precision_cumulee / total_exemples, erreur_cumulee / len(data_loader)

Avec ces fonctions, nous procédons au cycle complet sur 50 époques :

nb_epochs = 50
historique_loss = []
historique_acc = []
historique_val_loss = []
historique_val_acc = []

for i in range(nb_epochs):
    acc_t, loss_t = executer_entrainement(dataloader_train, model_instance, function_perd, optimisateur)
    acc_v, loss_v = verifier_performance(dataloader_test, model_instance, function_perd)
    
    historique_acc.append(acc_t)
    historique_val_acc.append(acc_v)
    historique_loss.append(loss_t)
    historique_val_loss.append(loss_v)
    
    print(f"Epoch: {i+1}, Accuracy Train: {acc_t:.4f}, Acc Val: {acc_v:.4f}")

Visualisation des Résultats

Analyser les courbes permet de détecter un éventuel surapprentissage. Nous représentons ici l'évolution de la perte et de la précision.

import matplotlib.pyplot as plt

plt.figure(figsize=(14, 5))

plt.subplot(1, 2, 1)
plt.plot(historique_acc, label='Acc. Entr.)
plt.plot(historique_val_acc, label='Acc. Validation)
plt.title('Evolution des Précisions')
plt.legend()

plt.subplot(1, 2, 2)
plt.plot(historique_loss, label='Perd. Entr.)
plt.plot(historique_val_loss, label='Perd. Validation)
plt.title('Evolution des Pertes')
plt.legend()

plt.show()

Opérations sur les Tenseurs

Il est parfois nécessaire de manipuler les dimensions des tenseurs intermédiaires.

  • Torch.squeeze(): Réduit les dimensions unitaires (ex: 1xHxW devient HxW).
  • Torch.unsqueeze(): Ajoute une dimension de valeur 1 à une position donnée (ex: pour introduire le canal batch).
# Exemple squeeze
x = torch.zeros(2, 1, 5, 1) # Taille [2, 1, 5, 1]
y = torch.squeeze(x)       # Résultat [2, 5]

# Exemple unsqueeze
z = torch.tensor([1, 2, 3])
w = z.unsqueeze(0)         # Résultat [[1, 2, 3]] (ajout axe batch)

Inférence et Persistance

Afin de conserver le modèle entraîné, nous sauvegardons uniquement les paramètres internes (state_dict). Cela permet de charger ultérieurement la configuration sans avoir besoin de recréer l'architecture exacte manuellement lors de la phase de production.

chemin_model = './mpox_classifier.pth'
torch.save(model_instance.state_dict(), chemin_model)

# Rechargement
modele_recharge = Module_Mpox().to(device)
modele_recharge.load_state_dict(torch.load(chemin_model, map_location=device))

Enfin, pour prédire sur une nouvelle image individuelle, il faut s'assurer que sa taille et sa transformation correspondent exactement au pipeline d'entraînement.

def inference_image_unique(chemin_img, modele, transfo, noms_classes):
    img_load = Image.open(chemin_img).convert('RGB')
    img_tens = transfo(img_load).to(device).unsqueeze(0)
    
    modele.eval()
    avec torch.no_grad():
        sortie = modele(img_tens)
        _, index_pred = torch.max(sortie, 1)
        
    classe_resultat = noms_classes[index_pred.item()]
    print(f"Prediction: {classe_resultat}")

Étiquettes: PyTorch ComputerVision CNN MachineLearning DataScience

Publié le 21 août à 11h02