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