Thématiques principales

samedi 14 juillet 2018

IA : Neuromimétique, la classification linéaire

Dans un article précédent [1], nous avons abordé la structure d'un neurone. Nous n'avions pas vu d'exemple pour illustrer son fonctionnement. Dans ce présent article, je vous propose de le compléter  en traitant d'un exemple de classification. Cela nous permettra d'explorer d'une part le fonctionnement intime du neurone et, d'autre part les grandes lignes du processus de mise en oeuvre d'un modèle au travers d'un exemple simple, celui du tri (binaire) de fruits: des ananas et des pastèques.

Le problème


Imaginons donc que l’on cherche à différencier des pastèques et des ananas. Ces fruits sont assez particuliers donc a priori on pourra facilement mettre en oeuvre un moyen de les discerner. Entre autre en les observant, on peut positionner 4 caractéristiques qui vont nous servir de critères suivant les ensemble suivant :
  • la rugosité -> 0 lisse a 1 rugeux
  • la couleur -> 0 bleu a 1 rouge
  • la forme -> 0 rond a 1 alongé
  • le poid -> 0 (20gr) à 1 (2000gr)
Ainsi pour chacun de fruits, des caractéristiques seront extraites et injecter dans les entrées de notre neurone (le vecteur X) tel que 


Sur cette base, nous pouvons constater une chose importante le neurone s'accommode bien de la nature des données, il est possible de mélanger des choux et des carottes… A l'inverse, du fait de la nature de poids, il est important d'avoir des données normalisés (de même ordre de grandeur) afin de ne pas biaiser le système en faveur d'une caractéristique plutôt qu'une autre. C’est pour cela que l’on place toutes les valeurs entre 0 et 1 (on aurait pu aussi se placer entre -1 et 1).

Visualiser les données

Nous avons pas vraiment de fruits alors pour l'exercice, je vous propose de nous donner un profil de fruits a partir duquel nous produirons deux ensembles nous permettant de travailler. Pour cela, exécutons le code python suivant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from random import randint, seed
import random
from mpl_toolkits.mplot3d import axes3d

import matplotlib.pyplot as plt
import numpy as np


def generateSet(prototype,nbrEchantillon,coef):
    rand_value=np.random.randn(len(prototype),len(prototype[0]))/coef
    #print(rand_value)
    rand_set=prototype+rand_value
    if nbrEchantillon == 0 :
        return prototype
    else:
        return np.concatenate((rand_set,generateSet(prototype,nbrEchantillon-1,coef)))

1
2
3
4
pasteque=np.array([[0.2, 0.3, 0.2, 0.95]])
anana=np.array([[0.8, 0.65, 0.6, 0.8]])
pasteques=generateSet(pasteque,1999,4)# -> pour separer les ensembles
ananas=generateSet(anana,1999,4)

Ensuite visualisons nos données selon plusieurs angles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
x=np.linspace(0,1,10)

fig = plt.figure(1,figsize=(15,15))
ax = plt.subplot(221, projection='3d')# equivalent a fig.addsubplot

ax.scatter(pasteques.T[0],pasteques.T[1],pasteques.T[2], c='b')
ax.scatter(ananas.T[0],ananas.T[1],ananas.T[2],c="r")
plt.xlabel('rugausité')
plt.ylabel('couleur')
ax.set_zlabel('forme')

ax = plt.subplot(222)
plt.plot(pasteques.T[0],pasteques.T[1],"b.")
plt.plot(ananas.T[0],ananas.T[1],"r.")
plt.xlabel('rugausité')
plt.ylabel('couleur')

ax = plt.subplot(223)
plt.plot(pasteques.T[0],pasteques.T[2],"b.")
plt.plot(ananas.T[0],ananas.T[2],"r.")
plt.xlabel('rugausité')
plt.ylabel('forme')

ax = plt.subplot(224)
plt.plot(pasteques.T[0],pasteques.T[3],"b.")
plt.plot(ananas.T[0],ananas.T[3],"r.")
plt.xlabel('rugausité')
plt.ylabel('poid')


Ainsi une pastèque aura le profil moyen autour des coordonnées [0.2, 0.3, 0.2, 0.95] et un ananas [0.8, 0.65, 0.6, 0.8] 

Bien sur l’exemple est simple et on se rend vite compte que d'une part les données sont déjà très séparées et que d'autre part certains critères seront plus utile que d’autre a la construction du paramétrage du neurone.

Paramétrage

Pour définir les paramètres du neurone, il faut avant tout choisir une fonction d’activation. Plusieurs choix se présente à nous, nous les avions vu dans l'article [1]: nous n'utiliserons pas une fonction linéaire car nous ne souhaitons pas prédire la taille des fruits mais les classer (nous reviendrons sur les prédictions dans un autre article sur la régression linéaire) donc il faut choisir entre une limiteur et une sigmoide. 

Commençons par le limiteur (nous regarderons plus tard ce que l'utilisation du sigmoïde apporte). Le limiteur à pour intérêt de fournir une réponse franche à notre problème de classification en nous donnant pour réponse soit 1 (on va dire une ananas) soit 0 on va dire une pastèque. A noter que si on voulait trier plus de type de fruits, ça ne serait pas possible et il faudrait considérer un nombre plus important de neurones mais faisons déjà avec un car maintenant que nous avons choisi une fonction d’activation, il nous reste encore à le paramétrer le neurone (les poids et le biais).

De façon empirique, la détermination des paramètres est la configuration permettant de discriminer les entrées pertinentes pour séparer les deux ensembles (ananas et pastèques) Ainsi, intuitivement et analytiquement on se rend compte que le plus simple est de s'appuyer sur les paramètres discriminant, et d’annuler ceux qui ne le sont pas. Pour le biais, tentons une moyenne des paramétrés choisis:

W=[1;1;1;0] avec un biais 1,5. Testons de facon analytique:

  • limiteur((Wt.pasteque)-biais)=limiteur( 0.4- 1.5) =limiteur(-1.1 )= 0
  • limiteur((Wt.anana)-biais)=limiteur( 2.35- 1.5) =limiteur(0.85 )= 1

Vérifions en le codant: (on implémente avant quelques fonctions permettant de calculer la sortie du neurone)

1
2
3
4
5
6
def neuroneLim(entre,W,biais):
    a=np.dot(entre,W.T)-biais
    #print("a neurone:",a)
    if a > 0:
        return 1
    return 0

1
2
3
4
5
6
#vecteur de classification
W=np.array([[1, 1, 1, 0]])
biais=1.5

print(neuroneLim(pasteque,W,biais))
print(neuroneLim(anana,W,biais))

Ok ça marche c’est cool mais on a paramétré un peu au pif (enfin pas tout à fait) mais essayons de comprendre pourquoi ça marche.

Interprétation

Pour comprendre pourquoi ça fonctionne, il faut s’intéresser au sens mathématique de l’opération de combinaison linéaire de l'entrée et des poids du neurone qui consiste en un produit scalaire. Ce produit va favoriser numériquement les vecteurs ayant une orientation perpendiculaire à une droite séparatrice qui modélisera la démarcation entre nos deux types de fruit. En utilisant le biais pour correctement ajuster la position de cette droite sur selon les ordonnées. 

Ainsi, si l’on représente W sur nos ensembles sous la forme de vecteur, on se rend compte, que orientation de celui ci suit un groupe et rejette l’autre:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
x=np.linspace(0,1,10)

fig = plt.figure(1,figsize=(15,15))
ax = plt.subplot(221, projection='3d')# equivalent a fig.addsubplot

ax.scatter(pasteques.T[0],pasteques.T[1],pasteques.T[2], c='b')
ax.scatter(ananas.T[0],ananas.T[1],ananas.T[2],c="r")
plt.xlabel('rugausité')
plt.ylabel('couleur')
ax.set_zlabel('forme')


ax = plt.subplot(222)
plt.plot(pasteques.T[0],pasteques.T[1],"b.")
plt.plot(ananas.T[0],ananas.T[1],"r.")
plt.xlabel('rugausité')
plt.ylabel('couleur')
plt.quiver(0.5,0.5,1,1,scale=5)
plt.plot(x,1-x)

ax = plt.subplot(223)
plt.plot(pasteques.T[0],pasteques.T[2],"b.")
plt.plot(ananas.T[0],ananas.T[2],"r.")
plt.xlabel('rugausité')
plt.ylabel('forme')
plt.quiver(0.5,0.5,1,1,scale=5)
plt.plot(x,1-x)

ax = plt.subplot(224)
plt.plot(pasteques.T[0],pasteques.T[3],"b.")
plt.plot(ananas.T[0],ananas.T[3],"r.")
plt.xlabel('rugausité')
plt.ylabel('poid')
plt.quiver(0.5,0.75,1,0,scale=5)
plt.plot(0.5+x*0,x+0.2)
Donc en fait regarder si un fruit est un ananas, c’est regarder le signe du produit scalaire des paramètres de ce fruit avec les paramètres du neurone…. cool! On a compris comment le neurone fonctionne pour séparer des classes mais…. À ce stade plein de questions devraient vous venir ! comme:
  • Comment mesurer l’efficacité du neurone? est il possible de faire des erreurs pourquoi et comment?
  • Si effectivement il y a des erreurs, alors comment déterminer le séparateur optimalement (ie déterminer de façon formelle les paramètres de W sans y aller à la louche)?

Efficacité et performance

Pour répondre la première question, c'est à dire mesurer l’efficacité d’un RN il faut aller interroger quelques algorithmes et concepts du machine learning. Dans le cadre de la classification, on pourrait se dire, bien il faut donner au neurone des fruits à trier et en connaissant les réponses, faire la différence des bonnes et mauvaises réponses. 

Effectivement c'est une idée, je vous invite a le faire mais sans surprise, nous avons que des 1 pour les ananas et que des 0 pour les pastèques. Cool en même temps ce n’est pas forcement très étonnant puisque nos ensemble sont bien linéairement séparable même en temps.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
resultP=[]
for val in pasteques:
    resultP.append(neuroneLim(val,W,biais))
   
resultA=[]
for val in ananas:
    resultA.append(neuroneLim(val,W,biais))

print("pasteques:",sum(resultP)," somme:", (2000-sum(resultP))*100/1999)
print("ananas:", sum(resultA),"somme:", sum(resultA)*100/1999)
    

1
2
pasteques: 71  somme: 96.49824912456228
ananas: 1815 somme: 90.79539769884943
Tiens pas complètement... Pourquoi?, avons nous négliger quelque chose? Pourtant nous avons construit nos ensembles nous même et on peut légitimement croire que nos données sont bonnes.

En fait, bien que séparable linéairement, nous avons produit nos données selon une loi normale. Il y a donc toujours une possibilité de superposition des composantes qui bien que peu marqué introduit une potentielle erreur. On le voit d’ailleurs sur certains graphes précédent. 

En fait tout n’est pas toujours aussi simple. On pourrait avoir une superposition des composantes? Que faire alors les éléments ne sont pas linéairement séparable?

Étudions cette nouvelle problématique en changeant un peu le coef de génération de familles de fruits rendant certains critères moins facile à discriminer. Reprenons nos scripts précédent et changeons le paramètre 10 par 4 (deuxième bloc de code).



Nos résultats, comme démontré par le schéma, ne sont plus les même car il existe des ananas ayant des caractéristiques proches de ceux des pastèques et réciproquement du coup le taux de capacité à les différencier passe à 97% et 90%... ça reste honnête malgré tout mais on sent bien que l’utilisation seule de ces taux n’est pas vraiment suffisant pour nous faire une idée de la qualité de notre configuration (surtout si elle est produite à la main) et puis pourquoi en avons nous deux? Il faut une autre approche.

La matrice de confusion

Pour évaluer correctement notre modèle de neurone, il existe en Machine learning un outil pour discriminer correctement les limites du classifier: la matrice de confusion.

Le matrice de confusion est un recueil statistique de la performance du modèle de classification en s'intéressant à la nature des prédictions en fonction des valeurs réelles.

Elle se composent ainsi d’une ligne par classe réelle et une colonne par classe prédite (une classe étant soit un type de classe où tout bêtement l’absence où la présence de la classe).

Dans notre cas, nous avons deux classes en exclusion mutuelle (2 types de fruits et si c’est pas l’un c’est l’autre) nous pouvons donc soit établir une matrice avec les deux fruits soit avec un seul fruit en considérant les classes présent et absent (qui facilite souvent la compréhension de la matrice).

Donc sur notre échantillon de 4000 fruits composé des 2000 pastèques et 2000 ananas, nous avons la matrice suivante:


  • 1938 Vrai Positif (c’est à dire les pastèques détectées comme tel)
  • 1798 Vrai Négatif (c’est à dire ananas détectés comme tel)
  • 62 Faux Négatif (c’est à dire une pastèque interprétées comme un ananas)
  • 202 Faux Positif (c’est à dire un ananas détectés comme une pastèque)
On se rend compte que nous avons une préférence pour les pastèques (enfin nous non, mais notre modèle oui!)  Pour affiner la matrice, nous allons ensuite définir deux critères supplémentaire:
  • la précision : VP/(VP+FP)= 1938/(1938+202) = 0.90 capacité à détecter des pastèques en présence d’ananas (0.90 de chance que le modèle réponde que le fruit est un ananas)
  • le rappel ou sensibilité : VP/(VP+FN)= 1938/(1938+62) =0.97 capacité à réellement détecter une pastèque dans un ensemble que de pastèques.
Ce qui signifie que quand le modèle déclare avoir une pastèque, il n’a raison que 90% du temps (ça veut dire que ça peut être aussi un ananas) et il n’est capable que d'en détecter que 97% parmi les seules pastèques. 

Sur la base de la précision et du rappel, on peut calculer F1 ou la moyenne harmonique qui permet de fournir une métrique combinant les deux taux:


L'analyse de ce taux permet de guider dans l'optimisation du modèle, cependant il faut garder a l'esprit précision et rappel sont deux facettes d'une même pièce et que l'augmentation de l'un amènera forcement a un diminution du résultat de l'autre mais selon le contexte, cela peut se justifier....

L'apprentissage

Maintenant que nous avons les moyens d’évaluer le modèle, il est nécessaire d'avoir une approche pour la définition des paramètres du neurone.

En effet, a l’aide de la matrice de confusion, on voit bien qu’il y a un problème, nous qui étions si confiant sur notre analyse et notre paramétrage. Sur un exemple aussi simple…. juste avec un neurone … qu’est ce qui se passé? Il faut croire que décidément, le gâteau est un mensonge et qu’il n’y a pas de repas gratuit!

Mais alors comment definir le parametrage?

En machine learning, pour optimiser le paramétrage des modèles, il existe des procédures d’entrainement. Elles se distinguent en 3 formes:
  • supervisés: on présente au modèle des entrées d'entrainement dont on connait la réponse (donc nos données d’apprentissages sont le couple (data,étiquette). En fonction de l’écart de réponse, on change le paramétrage selon un algorithme spécifique. 
  • non supervisé, le modèle va affiner par lui même les paramètres at runtime en détectant lui même les ensemble intéressant (approche par clustering)
  • semi-supervisé, où les deux approches précédentes sont associées
Allons au plus simple, considérons un apprentissage supervisé (nous aurons l'occasion d'explorer les autres) tel que:
  • si etiquete - sortie > 0 alors W=W+data 
  • si etiquete - sortie < 0 alors W=W-data
  • si etiquete - sortie = 0 alors W
Que l'on peut simplifier par l'algo suivant:

1
2
3
4
def majW(W, sortie, etiquette,entree):
    #print(W, sortie, etiquette,entree)
    return W+(etiquette-sortie)*entree
    

Ainsi l’algo d’apprentissage consiste a exposer le modèle à valeurs d'entraînements permettant la mise à jour de W en suivant les règles precedentes

Pour cela, il faut des données d’entrainement. On a déjà des données pour chaque fruit. on va donc étiqueter nos données et ensuite les mélanger afin d'éviter d’introduire un biais d’apprentissage en favorisant un fruit avant l’autre.

1
2
3
4
5
def addEtiquette(entreeSet,etiquette):
    dataEtiquete=[]
    for val in entreeSet:
        dataEtiquete.append([val,etiquette])
    return dataEtiquete

1
2
3
pasteques=addEtiquette(pasteques,0)
ananas=addEtiquette(ananas,1)
W=np.array([[1, 1, 1, 0]])


1
2
3
4
5
6
datas=pasteques+ananas
random.shuffle(datas)
print(len(datas))
datasApprentissage=datas[:3000]
datasTest=datas[3000:]
print(len(datasApprentissage)+len(datasTest))

Il nous faut penser à évaluer le modèle, donc de cet ensemble de données on ne va en prendre qu’une partie pour l’apprentissage et une autre pour évaluer la qualité de l’apprentissage en le testant.

Première phase: apprentissage supervisé


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
print("W initial:",W)    
for (val,etiquete) in datasApprentissage:
    sortie=neuroneLim(val,W,biais)
    W=majW(W, sortie, etiquete,val)

print("W final:",W)    
erreur=[0]


for (val,etiquete) in datasTest:
    sortie=neuroneLim(val,W,biais)
    #print(sortie,etiquete)
    if sortie != etiquete:
        erreur.append(erreur[len(erreur)-1]+1)
    else:
        erreur.append(erreur[len(erreur)-1])

Nous étions partie d'un W=[1 1 1 0] et nous avons maintenant W=[ 3.40784321 2.44027133 2.17143355 -1.26320997]

Deuxième phase test: 


1
2
3
4
5
6
erreur=[1]+[erreur[i]*100/i for i in range(1,1001)]
print(len(erreur))
fig = plt.figure(1,figsize=(15,15))
ax = plt.subplot(111)
index=np.linspace(0,1,1001)
plt.plot(index,erreur)

Avec ce diagramme, on visualise l’émergence de l'erreur au fur et a mesure que la quantité de données de test valide ou invalide le résultat de l’apprentissage. (donc même si on a le sentiment que l'erreur varie, intrinsèquement, celle du neurone est constante, c'est son évaluation qui s'affine).

Si on interprète le résultat en fonction de notre problématique de classification, on se rend compte que la superposition des classes pose un vrai problème pour le neurone qui reste incapable de séparer les deux ensembles. Il s'agit la d'une limitation classique de ce type de modèle.

Conclusion

Voila, nous sommes au bout de cet exemple qui complète l'article plus théorique sur la structure du neurone. Nous avons suivi un processus complet de traitement des données à classifier (en passant pas l'analyse, le POC et la visualisation) et réaliser des choix argumentés (a peu prêt) afin d’affiner et rendre plus pertinent notre modèle. Nous avons aussi utilisé des outils d’évaluation de notre modèle et considérer un algorithme simple d'apprentissage.

Bien sur nous devions également voir le cas de l'utilisation d'une fonction d'activation de type sigmoïde mais l'article est il me semble déjà assez long.... Je vous propose donc de revenir sur ces petits détails dans de futur (et pas trop lointain) articles.

Merci d'avance de ne pas hésitez a me donner des retours, qu'ils soient sur le fond comme sur la forme!

Références

[1] https://un-est-tout-et-tout-est-un.blogspot.com/2018/07/ia-approche-neuromimetique-le-neurone.html

Aucun commentaire:

Enregistrer un commentaire