Graphe et Algorithmes A*

publicité
Graphe et
Algorithmes
A*
PR-3602
COURIVAUD Raphaël et LY Diane
Graphe et Algorithmes A* 2015
Dans le cadre du projet PR-3602, nous avons dû résoudre le problème de l’affectation.
Notre but est d’affecter un employé à un poste particulier ou une machine à une tâche en
connaissant le coût ou la performance de ces derniers pour chaque tâches/postes.
Préparation
Pour comprendre au mieux le problème, les notions suivantes sont importantes :
Qu'est-ce qu'un Graphe de Résolution de Problème (GRP), relativement à un problème
donné ?
Afin de résoudre le problème donné qui est assez complexe, nous avons mis en place un
graphe de résolution de problème (GRP). Ce graphe permet de modéliser chaque état d’un
problème. En effet, à chaque nœud de notre graphe, on y associe un état de notre
problème. Chaque nœud possède un prédécesseur qui représente l’état précédant. Et
autant de successeur que d’état possible au “coup suivant”.
Le problème de l’affectation peut se modéliser lui-même par un graphe, plus précisément un
graphe biparti. (Un graphe est dit biparti s'il existe une partition de son ensemble de
sommets en deux sous-ensembles U et V telle que chaque arête ait une extrémité dans U et
l'autre dans V, cf. http://fr.wikipedia.org). Dans notre cas l’ensemble U représente les
différents employés que l’on doit affecter aux différents postes de l’ensemble V. Pour cela
nous avons à notre disposition une matrice représentant le coût de l’affectation de
l’employé i au poste j :
𝑎
𝑏
𝑐
𝑑
𝑒
(0,1,2,3,4) représente l’ensemble V
(a,b,c,d,e) représente l’ensemble U
1
0
1
2
3
4
23
16
|12
|
21
14
17
28
21
23
9
13
37
11
15
36
2
11
26
28
29
34
7
12||
31
18
Graphe et Algorithmes A* 2015
Quel GRP proposeriez-vous pour le problème de l'affectation ?
Nous nous basons donc sur un GRP composé de nœuds représentant des graphes biparti. A
chaque profondeur du graphe, nous avons une nouvelle affectation. On a donc une source qui
est le graphe biparti vide. Et des feuilles qui représentent un graphe biparti complet.
Quel est, schématiquement, le fonctionnement d'un algorithme A* ?
Tout d’abord l’algorithme A* est un algorithme qui permet de résoudre un problème
modélisé par un GRP en évitant d’explorer tous les nœuds. En effet le nombre de nœud d’un
GRP peut s’avérer très rapidement énorme. Et compte tenu de la rapidité de calcul des
ordinateurs nous pouvons très rapidement arriver à des programmes de durée extrêmement
longues si l’on doit explorer tous les nœuds.
L’algorithme A* repose un peu sur la base d’un algorithme glouton sans pour autant écarté les
autres possibilités. En effet, A* va toujours se diriger vers la solution optimale en développant
les nœuds correspondants aux nœuds les plus aptes à donner une solution optimale. Cependant
si “il s'aperçoit” qu’il existe un autre chemin plus optimal, il peut revenir en arrière afin de
développer un nouveau nœud. Cela est possible car l’algorithme garde en mémoire tous les
nœuds possibles dans une liste (OUVERT) et peut également, garder dans une liste (FERMÉE)
tous les nœuds déjà parcourus.
Que représentent les symboles g, h et f dans l'algorithme ?
f représente la fonction d’évaluation qui est indispensable pour obtenir un tri optimal de la
liste OUVERT. f(n), fonction qui estime le coût d’un chemin optimal du nœud de départ au
dernier nœud, peut s’écrire sous la forme :
f(n) = g(n) + h(n)
avec :

g(n) fonction qui estime le coût d’un chemin du nœud de départ et le nœud n à
développer.
h(n) fonction qui estime le coût d’un chemin optimal du nœud sélectionné n au
nœud choisi.

H représente la fonction heuristique.
Quelle est la condition sur h pour que l'on parle d'algorithme A* ?
Pour qu’on puisse parler d’algorithme A*, il faut que f(n) estime au mieux f*(n), ce qui revient à
estimer au mieux l’heuristique. Pour que l'algorithme garantisse un résultat optimal quand il
2
Graphe et Algorithmes A* 2015
s'arrête, il faut que l'estimation de h, h* soit strictement inférieur au chemin optimal" Ainsi, nous
avons travaillé sur trois heuristiques différentes, décrites ci-dessous : l’heuristique « line »,
l’heuristique « column » et la plus optimale, l’heuristique « maximum ».
Description détaillée de notre programme
Class Node() :
Nous avons, dans un premier temps, adapté la structure C qui nous était
fournie pour utiliser les fonctionnalités d’un langage objet tel que Python et créé un classe Node.
Cette classe contient comme attribut : son score g et h, le nombre d’affectation déjà réalisées
avec une liste de couple (i,j) (avec i, le i-ème employé et j, le j-ème poste,), ainsi que le nombre
totale d’affectation. Nous avons aussi définis quelques fonctions de base pour modifier les
attributs d’un nœud et redéfinis des fonctions systèmes comme __eq__(self, other) qui permet
de définir lorsqu’un nœud est ou non équivalent à un autre. Cela nous permet ainsi de
déterminer si un nœud est déjà ou non dans une liste (ici la liste OUVERT)
Méthode computeH() :
Nous avons ensuite mis en place le calcul des scores de chaque
nœud en fonction des différentes heuristiques que nous avions préalablement trouvées. Il était
très facile de trouver la valeur de g. Il suffit de récupérer toutes les affectations qui ont été
réalisées au nœud correspondant et de récupérer la valeur de ces dernières dans la matrice des
coûts. Dans un même temps nous créons deux listes qui contiennent tous les i et j (employés et
poste) qui ont été affectés, elles nous servirons pour le calcul de l’estimation de h.
En fonction de l’heuristique choisie nous devons maintenant définir la valeur de h*. On rappelle
que nos heuristiques, pour garantir l’optimalité du résultat, doivent donner un h* inférieur au
chemin optimal. Pour cela nous avons donné trois heuristiques différentes.
Tout d’abord l’heuristique ‘Line’ qui prend le minimum de chaque ligne sur la sous matrice des
employés et des postes qui n’ont pas encore été affectés. C’est dans cette partie que nos deux
listes des i et j nous servent. Nous parcourons toute la matrice en prenant en compte que les i et
j non affectés. Nous créons une liste pour chaque ligne et prenons le score minimum de chaque
ligne. Nous incrémentons alors l’estimation de h et passons à la ligne suivante. Nous observons
bien évidemment le même déroulement pour l’heuristique ‘Column’.
L’heuristique la plus optimale est de prendre le maximum des scores pour l’heuristique
‘Column’’ et ‘Line’. Nous devons calculer le score de h* pour les deux heuristiques et à chaque
noeud prendre celui qui est le plus élevé des deux.
Méthode developNode() :
Nous devons pour chaque état du GRP donner tous les
successeurs de ce nœud node, ainsi que tous les états suivants possibles.
Dans un premier temps nous avions mis en place un GRP qui prenait en compte tous les états
possibles à chaque profondeur : Au premier état du graphe, nous pouvions affecter tous les
3
Graphe et Algorithmes A* 2015
employés à tous les postes. Au deuxième état, nous éliminions l’employé et le poste déjà affecté,
et nous observions encore tous les cas possibles pour tous les employés et tous les postes.
Au nœud initial, nous avions alors NxN successeur, au deuxième (N-1)x(N-1), troisième (N-2)(N2) etc… Ce GRP était possible mais développait beaucoup trop de nœuds et n’était donc pas du
tout optimal pour notre cas. Notre programme marchait pour des petites matrices mais était
très vite dépassé lorsqu’on augmentait la taille de ces dernières.
Dans un deuxième temps, Mr Couprie nous a indiqué un graphe de résolution de problèmes plus
optimal qui consistait à développer seulement un employé à la fois à chaque profondeur. Si le
nœud de départ est le graphe biparti vide, on développe tous les états possibles pour l’employé
1 : on ne développe que N successeurs puis N-1, N-2 etc… On voit bien évidemment que la
quantité de nœuds développés à chaque coup est bien moindre que notre premier GRP.
Pour développer le nœud courant, on récupère tous les postes déjà affectés dans une liste qui
nous permet de ne pas parcourir ces derniers lors du balayage de la matrice. De plus, le i est fixé
puisque c’est l’affectation du i-ème employé pour la profondeur i. Il nous suffit donc de balayer
les colonnes pour une ligne définie. Cela nous permet de créer un nœud par colonne avec un
nombre d’affectation incrémenté par rapport à son “parent” et une nouvelle liste d’affectation.
Nous ajoutons un pointeur vers son parent grâce à un attribut de la class Node qui est une
instance de la même classe. Chaque nœud est ajouté dans une liste qui est retourné par la
fonction. On appelle ensuite Node.ComputeH() sur cette liste qui permet de calculer à chaque
fois le score g et h de chaque nœud.
4
Graphe et Algorithmes A* 2015
Le schéma ci-dessus nous montre le déroulement du programme sur une matrice 4x4 :
0
𝑎
𝑏
𝑐
𝑑
8
11
|
7
11
1
3
7
8
6
2
3
1
1
6
4
5
6
|
8
9
Avec pour légende :
Nœud avec b dans l’ensemble U et 0 dans l’ensemble V
Score d’un nœud présent dans la list_node
Score d’un nœud non présent dans la list_node (nœud retenu)
Retour en arrière pour obtenir un chemin plus optimal
Chemin optimal retenu (en pointillé : chemin optimal emprunté)
Méthode find_optimal_node() :
Pour trouver le nœud le plus optimal parmi les
nouveaux nœuds développés et mémorisés dans la liste list_node, on procède avec une boucle
for. Cette boucle prend chaque nœud de la liste, et calcule le score du nœud, puis mémorise ce
score dans une liste list_value. Afin de pouvoir retrouver le nœud avec le score le plus faible, on
stocke le nœud analysé dans un dictionnaire dico, avec pour index la valeur du score. Lorsque les
scores de tous les nœuds de la liste ont été calculés, on cherche la plus petite valeur (score) dans
cette liste list_value, et on récupère le nœud correspondant grâce au dictionnaire.
Dans le cas initial, la fonction retournera le premier nœud de la list_node.
Méthode AStar() :
Cette fonction AStar utilise comme le nom l’indique, l’algorithme A*.
Elle a pour fonction de faire tourner les méthodes afin d’obtenir le graphe optimal du problème.
Pour cela, elle crée deux listes : une liste list_open où seront mémorisés tous les nœuds
possibles et une liste list_close où seront mémorisés tous les nœuds déjà parcourus.
L’algorithme principal fonctionne ainsi : Dans un premier temps, on cherche et récupère le nœud
optimal dans la list_open en fonction de la matrice de coût cost_matrix, passée en paramètre,
puis on le retire de la liste_open afin de ne développer que sur les autres nœuds. Dans un
deuxième temps, on récupère la liste des nœuds développés. Puis pour tout nœud de cette liste,
si ce nœud n’existe pas déjà dans la list_open, on l’ajoute à la liste après avoir calculé son h(n) et
son g(n), et mis à jour ses attributs (tels que son nombre d’affectation, son parent …) grâce à la
méthode computeH(), en fonction de l’heuristique.
5
Graphe et Algorithmes A* 2015
On boucle l’algorithme principal tant que la list_open contient encore un nœud.
L’algorithme s’arrête lorsque le nombre d’affectation du nœud courant est égale au nombre
d’affectation voulu (i.e, n la taille de la matrice), ou par dans le pire des cas, lorsqu’on a exploré
tous les nœuds de la list_open, et qu’on n’a pas trouvé de graphe optimal.
Évaluation des performances
Pour tester les performances de notre programme, nous avons mis en place une boucle
incrémentant la taille de la matrice de test à chaque tour. Pour chaque taille de matrice nous
avons réalisés 40 tests. Chaque test consiste à créer une matrice aléatoire de taille n donné, et
d’appliquer les trois différentes heuristiques que nous avons implémenté. Nous gardons en
mémoire à chaque fois les performances des trois heuristiques dans des listes. Et grâce à Numpy
(un module python qui permet de réaliser des opérations mathématiques), nous avons donné la
moyenne et la variance de chaque heuristique sur les 40 échantillons pour une taille donnée.
Voici quelques exemples :
Taille des
matrices de Heuristique
test
Moyenne (variance)
Du temps d’exécution pour
chaque heuristique
Moyenne (variance)
De nœuds déployés
pour chaque
heuristique
5
Line
Column
Maximum
0.000574438822632 (0.0004026381283 )
0.00053237059792 (0.00028112905575)
0.000521975264024 (0.0001828508913)
7.333333333 (3.48648183066 )
6.9 ( 2.48126311919 )
5.866666667 ( 1.48847423745 )
8
Line
Column
Maximum
0.0383375999133 ( 0.12560504069 )
0.0335814500148 ( 0.0698285490013 )
0.0102747577085 ( 0.016772260109 )
36.35 ( 52.6880204601 )
37.5 ( 35.2171833059 )
19.0 ( 15.9232534364 )
9
Line
Column
Maximum
0.0787693727826 ( 0.111971510641 )
0.151824512556 ( 0.376406910524 )
0.0243683177829 ( 0.031099143415 )
54.1 ( 45.5520581313 )
69.2 ( 80.1421237552 )
27.35 ( 20.1897870222 )
10
Line
Column
Maximum
0.349614803125 ( 0.839995069526 ).
0.250117546787 ( 0.465658403239 ).
0.0502385796204 ( 0.057344323388).
84.7 ( 100.554512579 ).
84.25 ( 70.9135917861 ).
36.2 ( 23.5597113734 ).
11
Line
Column
Maximum
0.855757330486 ( 1.60341230828 ).
4.73643174125 ( 15.1206951371 ).
0.180613017185 ( 0.267163169929 ).
133.125 ( 132.427373964 ).
241.25 ( 412.443617359 ).
58.275 ( 50.8222330777 ).
12
Line
Column
Maximum
12.0446257055 ( 31.1436098682 ).
14.7537677992 ( 50.4511203336 ).
1.15692816786 ( 2.11371443979 ).
415.0 ( 503.889422393 ).
404.5 ( 662.087683015 ).
132.925 ( 132.680516185 ).
13
Line
Column
Maximum
37.0963980177 ( 135.223078997 ).
92.2706542926 ( 317.713958462 ).
3.25029983014 ( 7.76495459583 ).
601.475 ( 965.185137357 ).
682.6 ( 1226.18154855 ).
186.475 ( 229.183222281 ).
6
Graphe et Algorithmes A* 2015
On voit bien ici, sur ces différents exemples, que les différentes heuristiques possèdent bien des
performances différentes. L’heuristique maximum développe beaucoup moins de nœuds que les
deux première quel que soit la taille de la matrice et donc met beaucoup moins de temps.
Plus la taille de la matrice est grande, plus on voit que l’heuristique « Maximum » est beaucoup plus
performant : pour une matrice de taille 5, la moyenne du temps d’exécution est de 0.00057s pour
« line » contre 0.00052s pour « maximum ». Pour une matrice de taille 13, la moyenne du temps
d’exécution est de 37.096s pour « line » contre 3.25s pour « maximum ». Cela s’explique par le nombre
de nœuds déployés qui est triplé avec l’heuristique « line ».
On voit aussi qu’en moyenne l’heuristique « Line » est plus performante que celle « column », on
peut faire un lien avec notre GRP. En effet, on développe chaque nœud en fonction des
employés, employé 1, employé 2….. employé n. Donc on peut voir que l’estimation du minimum
ligne par ligne est sûrement plus proche de la solution optimale que celle colonne par colonne
qui dans notre problème est moins logique. C’est donc normal que l’on obtienne en moyenne
des parcours de graphe plus optimaux, et donc un temps d’exécution et nombre de nœuds
développés plus faible.
Conclusion
L’algorithme A*, aussi appelé A star (en anglais) par du principe que le plus court chemin pour
aller d’un point A à un point B est la ligne droite. Dans notre GRP c’est donc l’état le plus optimal
après l’état courant. A* est aussi utilisé pour répondre à de nombreux autres problèmes tel que
le « Voyageur de commerce » ou encore « le trajet optimal dans le métro ». En effet, son côté
simple et efficace, lui permet de résoudre des problèmes différents en adaptant seulement les
heuristiques. On peut par exemple penser pour ces deux problèmes à la distance la plus courte à
vol d’oiseau entre deux points. Grâce à cet algorithme et aux heuristiques choisis, cela permet
de parcourir le graphe de résolution de problème de façon optimale en évitant de développer
des nœuds qu’on sait d’avance étant non optimaux. L’algorithme développera toujours les
nœuds supposés plus proches de la solution optimale sans pour autant négliger les autres. C’est
ce qui le différencie d’un algorithme dit “Glouton”.
7
Téléchargement