SVM et les réseaux de neurones : classification multi-classes

Théorie : Exercices :

Pour les SVM

Les SVM sont de manière inhérente des classificateurs binaires. La méthode traditionnelle pour résoudre un problème multi-classes est d'utiliser la politique du "one versus the rest", c'est-à-dire d'attribuer un SVM par classe. Pour entraîner le classifieur pour la classe $y$, tous les exemples du jeu de données appartenant à la classe $k$ sont considérés comme des exemples positifs et tous les autres exemples comme négatifs. Un classifieur $C$ doit donc trouver l'hyperplan qui différencie les exemples de sa classe et les exemples des autres classes. Lors de l'inférence (c'est-à-dire lorsque l'on teste le système une fois entrainé), on attribue la classe $k$ à l'entrée $x$ du classifieur $C$ qui aura présenté la plus vaste marge pour ce même exemple $x$.

Nous pouvons tester un problème multi-classes avec SVM avec le jeu de données LFW (Labeled Faces in the Wild).

In [*]:
import matplotlib.pyplot as plt
from sklearn.multiclass import OneVsRestClassifier
from sklearn.datasets import fetch_lfw_people

faces = fetch_lfw_people(min_faces_per_person=60)
print("Nombre de classes:", faces.target_names.shape)
print(faces.target_names)
print("Dataset shape",faces.images.shape)

fig, ax = plt.subplots(3, 5)
for i, axi in enumerate(ax.flat):
    axi.imshow(faces.images[i], cmap='bone')
    axi.set(xticks=[], yticks=[],
            xlabel=faces.target_names[faces.target[i]])

plt.show()
Downloading LFW metadata: https://ndownloader.figshare.com/files/5976012
Downloading LFW metadata: https://ndownloader.figshare.com/files/5976009
Downloading LFW metadata: https://ndownloader.figshare.com/files/5976006
Downloading LFW data (~200MB): https://ndownloader.figshare.com/files/5976015
Nombre de classes: (8,)
['Ariel Sharon' 'Colin Powell' 'Donald Rumsfeld' 'George W Bush'
 'Gerhard Schroeder' 'Hugo Chavez' 'Junichiro Koizumi' 'Tony Blair']
Dataset shape (1348, 62, 47)

Pour cet exercice, nous allons faire une expérience en bonne et due forme. Dans la section précédente, nous testions le système directement sur les données utilisées pour l'entraînement : ce n'est pas correct. Pour tester l'efficacité de l'apprentissage d'une machine, lors de l'inférence, il faut donner des exemples qui n'ont jamais été vus par le système. On divise donc le jeu de données en deux parties : train se et test set.

In [*]:
from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(faces.data, faces.target,
                                                random_state=1)
print(Xtrain.shape)
print(Xtest.shape)
(1011, 2914)
(337, 2914)

Nous devons aussi soulever une deuxième question : que mettre en entrée de la machine ? Nous voyons dans la sortie précédente qu'un exemple (ou une image) a pour dimension $62 \times 47$ soit un vecteur $x \in \mathbb{R}^{2914}$. Peut-on se permettre de donner la matrice des pixels aplatis (flatten) en un vecteur en entrée ?

Le SVM a besoin en entrée de caractéristiques principales, ou discriminantes qu'il peut moduler pour tracer son hyperplan entre les classes. Une caractéristique, aussi appelée feature, est un élément composant le vecteur $x$. Ainsi, notre vecteur $x$ dispose de 2914 features, ici des pixels. Existe-il une corrélation entre les features que la SVM soit capable de les utiliser pour discriminer les classes ? Essayons :

In [*]:
from sklearn.svm import SVC
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
import time

model = OneVsRestClassifier(SVC(kernel='rbf', gamma="auto"))

start = time.time()
model.fit(Xtrain, ytrain)
end = time.time()
print(end - start)

yfit = model.predict(Xtest)

fig, ax = plt.subplots(4, 6)
for i, axi in enumerate(ax.flat):
    axi.imshow(Xtest[i].reshape(62, 47), cmap='bone')
    axi.set(xticks=[], yticks=[])
    axi.set_ylabel(faces.target_names[yfit[i]].split()[-1],
                   color='black' if yfit[i] == ytest[i] else 'red')
fig.suptitle('Predicted Names; Incorrect Labels in Red', size=14)

plt.show()
34.64870834350586 secondes

Non seulement le temps d'exécution est très long, mais les résultats sont relativement mauvais.

Pour palier à ce problème haute dimension (curse of dimensionality), une technique appelée PCA (Analyse en composante principales) est une méthode statistique (dont les principes ont été formulés en 1901 !) qui a trouvé son application en reconnaissance de visages et compression d'image. La PCA permet de trouver des patterns dans des données de hautes dimensions et de les exprimer dans une dimensionnalité réduite afin que les similarités et différences (c'est-à-dire variance et corrélation linéaire) entre les features soient mises en évidence.

Nous choisissons une PCA qui retient 150 features par image :

In [*]:
from sklearn.svm import SVC
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
import time


pca = PCA(n_components=150, whiten=True, random_state=1) #random is seed
svc = OneVsRestClassifier(SVC(kernel='rbf', gamma="auto"))
model = make_pipeline(pca, svc)

from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(faces.data, faces.target,
                                                random_state=1)

start = time.time()
model.fit(Xtrain, ytrain)
end = time.time()
print(end - start)
yfit = model.predict(Xtest)

fig, ax = plt.subplots(4, 6)
for i, axi in enumerate(ax.flat):
    axi.imshow(Xtest[i].reshape(62, 47), cmap='bone')
    axi.set(xticks=[], yticks=[])
    axi.set_ylabel(faces.target_names[yfit[i]].split()[-1],
                   color='black' if yfit[i] == ytest[i] else 'red')
fig.suptitle('Predicted Names; Incorrect Labels in Red', size=14)

plt.show()
1.281346082687378

La PCA permet de réduire l'erreur à 7/24 sur ces 24 exemples du test-set.

Exercice : en utilisant sk-learn, trouvez l'accuracy pour les 8 classes et imprimez la matrice de confusion

Nous pouvons vérifier le contenu du modèle OneVsRestClassifier, nous avons bien 8 SVM empilés :

In [*]:
print(len(model.named_steps['onevsrestclassifier'].estimators_))
print(model.named_steps['onevsrestclassifier'].estimators_)
8
[SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False), SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
    decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
    max_iter=-1, probability=False, random_state=None, shrinking=True,
    tol=0.001, verbose=False)]

Pour les réseaux de neurones

Nous allons maintenant réaliser le même exercice avec des réseaux de neurones. Grâce à sa topologie, une suite de transformations linéaires (via les perceptrons) et de fonctions non-linéaires (sigmoïde, tanh, relu,...), une couche cachée $h_t$ n'est pas contrainte de moduler les entrées "raw" (comme des pixels) mais bien la sortie de la couche $h_{t-1}$ (sauf la couche $h_0$ qui prend les pixels). Un réseau de neurones (NN) apprend donc ses propres features dans ses couches cachées nécessaires à la classification finale. Un NN avec deux couches cachées se représente comme tel :


On pourrait grossièrement attribuer à la couche cachée 1 la fonction de PCA et à la couche cachée 2 la fonction de réduction de dimensionalité.
Il est important d'ajouter que la taille des couches est modulable, on peut donc avoir $k$ sorties pour le multi-classes, il suffit de changer la taille de la couche de sortie :


Cet avantage est non négligable : les jeux de données utilisés pour créer les systèmes de reconnaissance visuelle à la pointe comprennent jusqu'à 1000 classes (voir ImageNet).
Similarités : Différences :

In [*]:
import numpy as np
import matplotlib.pyplot as plt
import torch.nn.functional as F
import torch.nn as nn
import torch
In [*]:
from sklearn.datasets import fetch_lfw_people
from sklearn.model_selection import train_test_split
In [*]:
faces = fetch_lfw_people(min_faces_per_person=60)
print("Nombre de classes:", faces.target_names.shape)
print(faces.target_names)
print("Dataset shape",faces.images.shape)
Nombre de classes: (8,)
['Ariel Sharon' 'Colin Powell' 'Donald Rumsfeld' 'George W Bush'
 'Gerhard Schroeder' 'Hugo Chavez' 'Junichiro Koizumi' 'Tony Blair']
Dataset shape (1348, 62, 47)
In [*]:
#Les images doivent être en 3D. Ici les images sont en noir et blanc, donc la première dim
# est 1. Dans le cas du RGB, la dimension est de 3
images_reshape = np.expand_dims(faces.images, axis=1) #1348 x 1 x 62 x 47
Xtrain, Xtest, ytrain, ytest = train_test_split(images_reshape, faces.target,
                                                random_state=1)

# transformation des inputs numpy vers torch
X = torch.from_numpy(Xtrain).cuda().float()
y = torch.from_numpy(ytrain).cuda().long()

# network dimensions
n_input_dim = X.shape[1]
n_output = faces.target_names.shape[0]  # output = nombre de classes dans le dataset (8)

Pour construire le modèle, nous utilisons ici des convolutions (conv2d). C'est un cas particulier des couches linéaires pleinement connectées comme vu précédemment où certaines connections sont coupées :

Nous verrons plus en détails les propriétés des convolutions dans les prochaines sections.

In [*]:
# Build the network
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.conv3 = nn.Conv2d(16, 32, 5)
        self.fc1 = nn.Linear(32 * 4 * 2, 120)
        self.fc2 = nn.Linear(120, n_output)
        self.dropout = nn.Dropout(p=0.2)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = self.pool(F.relu(self.conv3(x)))
        x = x.view(-1, 32 * 4 * 2)
        x = self.dropout(x)
        x = F.relu(self.fc1(x))
        x = self.dropout(x)
        x = F.relu(self.fc2(x))
        return x
In [*]:
net = Net().cuda()

loss_func = nn.CrossEntropyLoss() #fonction de la loss
learning_rate = 0.002 #pas d'apprentissage
optimizer = torch.optim.SGD(net.parameters(), lr=learning_rate, momentum=0.9) #descente de gradient
num_iterations = 5000
batch_size = 128

for i in range(num_iterations):
    rand = np.random.choice(X.shape[0], batch_size)
    input = X[rand]
    target = y[rand]

    optimizer.zero_grad()

    # forward pass
    prob = net(input)

    #calcul de la loss
    loss = loss_func(prob, target)

    #mise à jour du réseau
    loss.backward()
    optimizer.step()

    #print de la loss
    if i%200==0:
        print("Iter {} - loss: {:.2f}".format(i,loss.item()))


net.eval()
x = torch.from_numpy(Xtest).cuda()
probs = net(x)

#le réseau sort des probabilités pour chaque classe (chaque réponse possible)
#on prend la confiance "max" du réseau comme réponse
answers = torch.argmax(probs, dim=1)
answers = answers.detach().cpu().numpy()

fig, ax = plt.subplots(4, 6)
for i, axi in enumerate(ax.flat):
    axi.imshow(Xtest[i].reshape(62, 47), cmap='bone')
    axi.set(xticks=[], yticks=[])
    axi.set_ylabel(faces.target_names[answers[i]].split()[-1],
                   color='black' if answers[i] == ytest[i] else 'red')
fig.suptitle('Predicted Names; Incorrect Labels in Red', size=14)
plt.show()
Iter 0 - loss: 2.19
Iter 200 - loss: 0.48
Iter 400 - loss: 0.20
Iter 600 - loss: 0.06
Iter 800 - loss: 0.02
Iter 1000 - loss: 0.04
Iter 1200 - loss: 0.00
Iter 1400 - loss: 0.00
Iter 1600 - loss: 0.04
Iter 1800 - loss: 0.00
Attention : Nous notons que nous utilisons plus la BCELoss (binary cross entropy loss, pour deux classes) qui est un cas particulier de la categorical cross entropy (ici CrossEntropyLoss) généralisé pour un nombre infini de classes. Nous remarquons aussi la disparition de la sigmoïde. La sigmoïde a été remplacée directement dans la fonction CrossEntropyLoss [SOURCE] par la fonction Softmax qui est une généralisation de la sigmoïde. Soit un vecteur de probabilité $p$, la fonction sigmoïde écrase la probabilité $p_i$ indépendamment des autres probabilités dans le vecteur. La fonction Softmax mettra cette probabilité en relation de grandeur avec les autres, afin que la somme des probabilités régularisées vaille 1.
In [*]:
import torch

x = torch.tensor([0.5, 1.8, 0.1])
print(torch.sigmoid(x))
print(torch.sigmoid(x).sum(0))
print(torch.nn.functional.softmax(x, dim=0))
print(torch.nn.functional.softmax(x, dim=0).sum(0))
tensor([0.6225, 0.8581, 0.5250])
tensor(2.0056)
tensor([0.1873, 0.6872, 0.1255])
tensor(1.0000)
Ce choix de régularisation peut sembler arbitraire, mais la fonction Softmax est simplement une généralisation de la fonction sigmoïdale pour des problèmes multi-classes (à plusieurs sorties). Utiliser la fonction Softmax pour un problème binaire est équivalent à utiliser une sigmoide. Nous voyons qu'il est d'ailleurs facile de passer d'une fonction à l'autre : $$\text{sigmoid}(x)=\frac{1}{1 + e^{- x}}$$ $$\text{softmax}(\mathbf{x})_i = \frac{e^{x_i}}{\sum_{j=1}^K e^{x_j}} \text{ for } i = 1, \dotsc , K \text{ and } \mathbf x=(x_1,\dotsc,x_K) \in\mathbb{R}^K$$ Dans la fonction Softmax, chaque probabilité $x_i$ est divisée (et donc mise en relation) par la somme de tout les probabilités $x_1, ..., x_K$.

Exercice : pour un exemple du test-set, sortez les probabilités, normalisez-les avec la fonction torch.nn.softmax et donnez la confiance (probabilité) avec laquelle le modèle apporte sa réponse