Activité pratique
Durée2h30Consignes globales
Lors de la séance précédente, vous avez vu qu’aucun algorithme exact ne permet d’attraper en un temps raisonnable les 21 morceaux de fromage d’une partie par défaut. Dans cette activité, vous allez donc développer un algorithme heuristique, qui trouve rapidement une bonne solution au TSP, sans garantie qu’elle soit optimale : un algorithme glouton. Nous allons travailler avec la configuration suivante :
- Un seul joueur, puis deux joueurs qui s’affrontent.
- Beaucoup de morceaux de fromage (20, par exemple).
- De la boue dans le labyrinthe (20% des arêtes, par exemple).
Vous rendrez ensuite votre glouton réactif, pour qu’il s’adapte à un adversaire. Dans les parties optionnelles, nous vous proposons d’améliorer les solutions trouvées (la partie sur 2-opt n’est pas très difficile, et elle est intéressante à tester), de factoriser vos codes, et d’essayer d’autres heuristiques.
Important
Le code produit lors de cette séance (et de la précédente) fait partie du second livrable évalué. Consultez les détails sur la page de la séance.
Contenu de l’activité
L’algorithme glouton
Préparation
À faire
Créez les fichiers nécessaires dans votre workspace PyRat en suivant les instructions ci-dessous. Pour le moment, ne vous préoccupez pas du comportement de votre joueur, créez juste le strict minimum pour pouvoir lancer une partie sans que ça crashe.
-
players/greedy.py— Créez un nouveau joueurGreedy, héritant de la classePlayer. -
games/visualize_greedy.py— Créez un script qui lancera une partie avec un joueurGreedy, dans un labyrinthe respectant les critères listés plus haut (argumentsmud_percentageetnb_cheese). Vous pouvez éventuellement définir unerandom_seed(pour toujours travailler avec le même labyrinthe) et unetrace_length(pour suivre la trajectoire de votre joueur). -
tests/test_greedy.py— Créez un fichier qui contiendra les tests unitaires de votre joueur. Pour le moment, créez juste le fichier avec une classe de tests copiée-collée detest_exhaustive_player.py(ou detest_dijkstra.py), en changeant le nom de la classe et en enlevant les méthodes de test.
Information
Comme pour la séance précédente, vous pouvez travailler dans un labyrinthe sans boue, avec un parcours en largeur, si vous êtes en retard sur l’algorithme de Dijkstra. Néanmoins, notez que pour le second livrable, qui contient le code de cette séance, un labyrinthe avec de la boue et l’algorithme de Dijkstra sont attendus.
Découper le problème
Nous voulons utiliser l’heuristique suivante :
Le chemin le plus court qui passe par tous les morceaux de fromage peut être approximé en allant successivement vers le morceau de fromage le plus proche.
Plus tard dans cette activité pratique, nous vous demanderons de changer l’heuristique utilisée par votre algorithme glouton, et de le rendre réactif. Réfléchissez donc attentivement au découpage en méthodes : n’écrivez pas tout dans une seule méthode !
À faire
Identifiez les méthodes nécessaires. N’hésitez pas à revenir aux articles que vous deviez étudier pour aujourd’hui, et aux méthodes écrites lors de la séance précédente. Une fois identifiées, écrivez leurs prototypes (la ligne def xxx (...):, une documentation, et un contenu égal à pass) dans votre fichier greedy.py.
À faire
Identifiez les tests unitaires nécessaires pour chaque méthode. Pour chaque test, déterminez s’il correspond à une utilisation normale, à un cas limite, ou à une erreur possible. Une fois identifiés, écrivez leurs prototypes dans votre fichier test_greedy.py.
Implémentation
À faire
Programmez les méthodes dans greedy.py et les tests dans test_greedy.py. N’oubliez pas d’exécuter vos tests régulièrement pour vous assurer que tout fonctionne correctement.
À quoi ça ressemble ?
À présent, utilisez votre script de visualisation pour observer votre joueur. La vidéo suivante montre un exemple de ce que vous devriez observer :
Comparer avec l’algorithme exhaustif
Comme vous l’avez vu dans les articles à étudier, un algorithme approché est un compromis entre précision, complexité, et parfois d’autres propriétés (mémoire requise, besoin de matériel spécifique…). Comparons votre joueur Greedy aux joueurs exacts de la séance précédente.
À faire
Créez un script dans le sous-répertoire games, qui trace (pour chacun de ces joueurs) une courbe du nombre de tours nécessaires pour attraper tous les morceaux de fromage, en fonction de leur nombre dans le labyrinthe. Mesurez aussi le temps de calcul (les statistiques renvoyées par game.start() contiennent la durée du prétraitement de chaque joueur). Que gagne-t-on, et que perd-on, avec l’algorithme glouton ?
Évidemment, vous ne devriez pas aller très haut en nombre de morceaux de fromage pour les joueurs exacts. De plus, il est préférable de moyenner les résultats sur plusieurs parties, car il peut y avoir de la variabilité entre les parties. Comme lors des séances précédentes, faites jouer les joueurs sur les mêmes labyrinthes : cela permet de vérifier des propriétés qui doivent être vraies sur chaque labyrinthe.
Un glouton réactif
Jusqu’à présent, vous avez probablement suivi le même raisonnement que dans les séances précédentes : calculer le chemin dans preprocessing(...), et le dérouler à chaque appel de turn(...).
Maintenant, imaginons que vous ayez un adversaire dans le jeu. Si vous suivez un tel chemin prédéterminé sans considérer que votre adversaire peut manger certains morceaux de fromage entre-temps, vous allez sûrement perdre. En effet, vous allez essayer d’aller chercher des morceaux de fromage qui ont disparu, tandis que l’adversaire se concentrera sur ceux qui restent.
Une première amélioration
Revenons sur le fonctionnement de l’algorithme glouton :
- Considérez que vous êtes sur une case particulière du labyrinthe, et que vous exécutez votre algorithme glouton.
- La sortie devrait être une liste de morceaux de fromage à manger, triée dans l’ordre dans lequel les obtenir.
- Vous vous dirigez donc vers le premier morceau de fromage de cette liste.
Toutefois, au lieu de calculer cette liste une seule fois au début du jeu, vous pouvez la recalculer chaque fois que vous atteignez un morceau de fromage. Cela permet de s’adapter aux changements dans le labyrinthe, tels que les morceaux de fromage qui ont été mangés par l’adversaire.
À faire
Créez un nouveau joueur GreedyEachCheese, dans un fichier players/greedy_each_cheese.py, qui réalise cette amélioration. Créez également un script games/visualize_greedy_each_cheese.py pour lancer une partie avec celui-ci.
N’oubliez pas les tests : comment tester un joueur dont le comportement dépend de ce qui se passe pendant la partie ?
Encore mieux
Le joueur GreedyEachCheese a encore un inconvénient. En effet, si l’adversaire récupère le morceau de fromage vers lequel nous nous dirigeons, nous terminerons quand même notre trajectoire.
Afin de le rendre encore plus réactif, repensons notre GreedyEachCheese :
- Considérez que vous êtes sur une case particulière du labyrinthe, et que vous exécutez votre algorithme glouton.
- La sortie devrait être le prochain morceau de fromage à récupérer (appelons-le
c). - Une fois que vous avez déterminé
c, vous effectuez une action dans sa direction.
Si nous faisons un pas dans la direction de c, nous pouvons distinguer les deux scénarios suivants :
cest toujours là — Alors,cest toujours le morceau de fromage le plus proche, car nous avons réduit le nombre de tours nécessaires pour y aller de 1 (ou plus s’il y avait de la boue). Nous devrions continuer dans sa direction.ca été attrapé par un adversaire — Alors, nous devrions nous concentrer sur un autre morceau de fromage.
Notez que nous pouvons traiter les deux scénarios en exécutant notre algorithme glouton à chaque tour pour déterminer la prochaine action (et non le prochain morceau de fromage comme GreedyEachCheese). En faisant cela, nous trouverions le même chemin à suivre si le fromage est toujours là, et nous nous déplacerions ailleurs si nécessaire.
À faire
Créez un nouveau joueur GreedyEachTurn, dans un fichier players/greedy_each_turn.py, qui réalise cette amélioration. Créez également un script games/visualize_greedy_each_turn.py pour lancer une partie avec celui-ci. N’oubliez pas les tests.
Comparer les programmes
Vous avez maintenant trois algorithmes gloutons. Essayons de les comparer pour voir si les améliorations apportées ont un impact sur les résultats.
À faire
Créez un script de jeu à deux équipes games/match_greedy_greedy_each_turn.py pour faire s’affronter Greedy et GreedyEachTurn. Pour ce faire, inspirez-vous du script games/sample_game.py.
Vous devriez faire démarrer les joueurs à des emplacements aléatoires en utilisant un code similaire à celui-ci :
Sinon, les deux joueurs commenceront au milieu du labyrinthe, et suivront les mêmes trajectoires.
À faire
En moyenne sur 100 parties, quel joueur gagne ? De combien de parties ? Est-ce significatif ?
Pour jouer autant de parties, inspirez-vous du script games/evaluate_random_players.py des séances précédentes : utilisez le mode GameMode.SIMULATION, qui joue les parties sans affichage et beaucoup plus vite.
Pour une comparaison équitable, jouez chaque labyrinthe deux fois, en échangeant les positions de départ des deux joueurs.
Pour ce faire, vous pouvez utiliser un test de Student pour un échantillon, qui vérifie si la liste des différences de scores a (ou non) une moyenne de 0.
À faire
Créez également un autre script de jeu games/match_greedy_each_cheese_greedy_each_turn.py pour faire s’affronter GreedyEachCheese et GreedyEachTurn.
À faire
Est-il intéressant d’exécuter l’algorithme glouton à chaque tour plutôt qu’à chaque morceau de fromage (du point de vue des résultats) ? S’il y a une amélioration, est-elle significative ?
Pour aller plus loin
2-opt
Il existe de nombreux autres algorithmes approchés que vous pouvez utiliser pour approximer la solution au TSP. Nous pouvons distinguer deux catégories :
- Solution unique — Une seule solution au problème TSP est trouvée, puis éventuellement améliorée pour obtenir une meilleure solution. L’algorithme glouton entre dans cette catégorie.
- Solutions multiples — De nombreuses solutions sont trouvées et mélangées pour produire de meilleures solutions. Les algorithmes génétiques entrent dans cette catégorie.
L’algorithme glouton construit une solution unique et l’utilise ensuite. Il n’effectue aucune amélioration de cette solution, bien que certaines améliorations simples soient possibles.
Une heuristique simple pour s’appuyer sur le résultat glouton est 2-opt. L’algorithme part d’une solution particulière au TSP (soit aléatoire, soit construite avec un algorithme glouton), et l’améliore progressivement en supprimant les croisements du chemin.
À faire
Essayez de coder cette heuristique en suivant les indications sur la page Wikipédia sur 2-opt, ou d’autres ressources. Quel gain apporte-t-elle par rapport à l’algorithme glouton ?
Information
2-opt est un nouvel algorithme pour résoudre (de manière approchée) le TSP sur le méta-graphe de la séance précédente. Si vous avez factorisé vos codes lors de la séance 4, écrivez-le comme un nouveau solveur, sous-classe de TSPSolver (dans un fichier utils/tsp/two_opt_tsp_solver.py), et ajoutez-le aux algorithmes proposés par TSPPlayer. Sinon, créez un nouveau joueur, qui construit le méta-graphe comme votre joueur ExhaustivePlayer.
Factoriser les codes
Avant d’essayer d’autres heuristiques (voir la section suivante), il est intéressant de factoriser votre code : ajouter une heuristique sera ensuite beaucoup plus simple. Comme lors des séances précédentes, l’idée est de séparer le joueur de l’algorithme qu’il utilise : ici, l’heuristique qui choisit le prochain morceau de fromage.
À faire
Créez une classe abstraite GreedyHeuristic (dans un fichier utils/greedy/greedy_heuristic.py), et des classes enfants qui encodent les heuristiques, dans le même dossier utils/greedy. Enfin, créez un joueur players/greedy_player.py, qui utilise l’heuristique de votre choix à chaque tour, comme GreedyEachTurn. Comme TSPPlayer lors de la séance 4, ce joueur reçoit l’heuristique à utiliser à sa création.
Voici un diagramme UML qui représente une factorisation possible. Consultez la séance 2 pour un guide sur la lecture de celui-ci.
Dans cette organisation :
- Chaque heuristique donne un score à chaque morceau de fromage restant (méthode abstraite
score(...)), etselect_next_cheese(...)choisit celui de plus petit score. - Les heuristiques ont besoin de résultats de l’algorithme de Dijkstra, éventuellement depuis d’autres cases que la position du joueur. La méthode
get_traversal(...)les fournit, en les mémorisant (voir la section « Réutiliser les parcours »). - Au début de chaque partie, le joueur appelle la méthode
preprocessing(...)de l’heuristique, qui vide la mémoire des parcours. Les heuristiques qui ont besoin de calculs préalables (par exemple, les parcours depuis tous les morceaux de fromage) la redéfinissent. GreedyPlayercrée l’heuristique choisie à sa création, et l’utilise à chaque tour.
Une autre heuristique
Jusqu’à présent, votre heuristique était de favoriser le morceau de fromage le plus proche. Voici deux autres heuristiques possibles que vous pouvez implémenter.
À faire
Meilleurs $k$ morceaux de fromage — Favorisez le morceau de fromage c tel que la série de $k$ morceaux de fromage commençant par c soit la plus courte.
Pour ce faire, vous pouvez résoudre un TSP jusqu’à la profondeur $k$.
À faire
Zones de haute densité — Favorisez les morceaux de fromage qui sont situés dans des zones où il y a beaucoup de fromage.
Pour ce faire, vous devez calculer une mesure de densité pour chaque morceau de fromage.
Réutiliser les parcours
Vous avez peut-être déjà remarqué que vous exécutez plusieurs fois l’algorithme de Dijkstra au cours du jeu. Parfois, vous utilisez même l’algorithme plusieurs fois à partir du même endroit. Cela peut arriver par exemple lorsque vous exécutez l’algorithme à partir d’une position que vous avez déjà visitée plus tôt dans le jeu, ou lors du calcul d’une heuristique à partir d’un morceau de fromage.
Puisque c’est une opération coûteuse, il peut être judicieux de stocker les résultats dans un attribut pour éviter de recalculer les tables de routage si vous l’avez déjà fait. C’est une approche que vous avez vue en cours, appelée mémoïsation.
Une solution rapide est de créer un attribut, et de vérifier d’abord son contenu avant d’exécuter le code coûteux :
À faire
Essayez de l’implémenter dans votre joueur glouton en utilisant le code ci-dessus comme guide. Quel temps gagnez-vous ?
Pour aller encore plus loin
Recherche locale
La recherche locale est une méta-heuristique qui explore l’espace des solutions comme un parcours dans un graphe. L’idée est que nous allons créer un graphe de solutions dynamiquement, et l’explorer jusqu’à ce que nous soyons bloqués dans un optimum local. Plus précisément, pour implémenter une recherche locale, vous pouvez :
-
Partir d’une solution donnée $s$.
-
Identifier toutes les solutions voisines de $s$. Pour ce faire, vous devez définir une opération qui, lorsqu’elle est appliquée à $s$, générera de nouvelles solutions. Par exemple, vous pourriez permuter deux sommets dans la solution. Cela générera une solution voisine pour chaque paire de sommets.
-
Évaluer les voisins, et votre nouveau $s$ devient la meilleure solution parmi les voisins.
-
Boucler jusqu’à ce qu’aucun voisin ne soit meilleur que $s$.
À faire
Essayez différentes stratégies pour générer des voisins. Vous pouvez également essayer différentes stratégies d’exploration des voisins, inspirées par des algorithmes tels que BFS ou DFS.
Recuit simulé
Le recuit simulé est une méthode efficace pour approcher la solution du TSP. Comme pour la recherche locale, vous avez besoin d’une méthode pour générer des voisins. Vous pouvez ensuite appliquer le recuit simulé comme suit :
-
Partir d’une solution initiale $s$.
-
Initialiser le recuit simulé :
- Définir la température initiale $T$.
- Choisir une stratégie de décroissance de température, par exemple, décroissance exponentielle $T \leftarrow \alpha \cdot T$, où $\alpha$ est légèrement inférieur à 1 (par exemple, 0,99).
- Définir un critère d’arrêt, tel qu’un nombre maximum d’itérations ou une température minimale.
-
Pour chaque itération :
- Générer une nouvelle solution $s'$ en modifiant légèrement $s$.
- Calculer l’augmentation de longueur $\delta = L(s') - L(s)$.
- Si $s'$ est meilleure, l’accepter.
- Si $s'$ est moins bonne, l’accepter avec une probabilité dépendant de la température actuelle et de la différence de coût. Cette probabilité est calculée comme $P = \exp\left(-\frac{\delta}{T}\right)$.
- Réduire la température selon la stratégie de décroissance.
-
Arrêter lorsque la température est suffisamment basse ou après un nombre prédéfini d’itérations.
À faire
Essayez d’implémenter le recuit simulé pour le TSP en utilisant les étapes ci-dessus comme guide.