Cette activité pratique a pour but de vous préparer au projet PyRat. Elle consiste en trois grandes parties :
Vous allez installer le logiciel.
Vous allez mettre en place un dépôt Git, afin de travailler efficacement en groupe.
Enfin, vous serez guidé(e)s dans l’écriture de votre premier programme PyRat.
Cette mise en place peut être assez longue, mais elle est essentielle pour bien attaquer le projet. Si vous ne parvenez pas à tout faire dans la séance, terminez en dehors avant la prochaine.
N’oubliez pas que nous pouvons vous aider entre les séances via le serveur Discord du cours. Vous devriez dès à présent le rejoindre si ça n’est pas déjà fait.
Contenu de l’activité
Où trouver PyRat ?
Le plus important à connaître est le site principal du logiciel PyRat, qui centralise tous les éléments de prise en main :
Documentation : cette page regroupe les éléments de documentation sur comment installer et utiliser PyRat.
La documentation présente aussi l’API PyRat, c’est-à-dire les classes et fonctions fournies par la bibliothèque.
Nous y avons également ajouté quelques tutoriels, qui constituent la base de cette activité pratique.
Important
Comme pour la plupart des logiciels que vous rencontrerez dans votre vie, le code et la documentation de PyRat sont rédigés en anglais.
Dans ce projet, nous vous demandons de produire vos codes, commentaires et documentations en anglais également.
C’est une pratique essentielle dans le métier d’ingénieur(e), où vous serez amené(e)s à travailler dans des contextes internationaux.
Voici également quelques liens secondaires, qui pourront vous être utiles plus tard :
PyPI : la page de référence du paquet pyrat-game, c’est-à-dire la bibliothèque PyRat telle qu’elle sera téléchargée et installée dans votre workspace.
GitHub : ce dépôt GitHub contient les codes sources du logiciel PyRat. Quand nous travaillons à une nouvelle version du logiciel, c’est là que nos modifications sont publiées.
GitHub issues : les issues GitHub sont une sorte de forum, où sont discutés les possibles problèmes rencontrés par les utilisateurs lors de l’installation ou l’utilisation du logiciel. C’est une bonne pratique de consulter cette page si vous rencontrez des difficultés.
Préparer l’environnement du projet
Dans cette section, nous allons préparer l’environnement de travail pour le projet PyRat. PyRat automatise une grande partie de l’installation via uv.
Dans ce projet, nous allons travailler avec l’IDE VSCode. Vous pouvez en utiliser un autre si vous préférez, il vous suffira d’adapter les instructions.
À faire
Ouvrez un terminal dans le dossier où vous voulez créer les fichiers du projet (ne créez pas de projet via uv pour l’instant). Un bon emplacement serait dans un dossier IMT/S5/info/projet par exemple (adaptez selon ce que vous avez déjà).
Correction
Cliquez ici pour afficher la correction.
C’est assez direct. Penser à ne pas tout mettre en vrac sur le bureau. Voici une bonne arborescence :
IMT
|_ S5
|_ |_ info
|_ |_ |_ projet
Vérifier que uv est installé
À faire
En utilisant le terminal ouvert précédemment, vérifiez que uv est bien installé sur votre machine. Si ça n’est pas le cas, installez-le.
Correction
Cliquez ici pour afficher la correction.
Pour vérifier que uv est installé : uv --version. Si un numéro de version s’affiche, tout va bien.
Si la commande n’est pas trouvée, uv s’installe en une seule commande, comme vu en séance 2 d’environnement. Pensez à fermer puis rouvrir le terminal après l’installation, pour que la commande uv soit trouvée.
Télécharger et installer PyRat
PyRat s’installe facilement grâce à uvx, un outil fourni avec uv qui permet d’exécuter des programmes distribués via PyPI, sans avoir besoin de créer un environnement virtuel au préalable.
À faire
Trouvez la page décrivant l’installation dans la documentation de PyRat, et suivez les instructions.
Information
La commande d’installation demande à uv d’utiliser Python 3.14 (option --python 3.14). PyRat fonctionne avec toutes les versions de Python de 3.10 à 3.15, mais certaines bibliothèques que vous pourriez ajouter plus tard, comme OR-Tools, ne sont pas encore disponibles pour Python 3.15.
C’est d’ailleurs l’une des forces de uv : il peut figer la version de Python utilisée par un programme ou un projet, et la télécharger si elle n’est pas présente sur la machine. Ici, pyrat-init enregistre la version choisie dans le fichier .python-version du workspace. Ce fichier est versionné avec vos programmes : tout le groupe travaille alors avec la même version, quelle que soit celle installée sur chaque ordinateur, ce qui rend les résultats reproductibles.
La commande d’installation devrait créer un dossier pyrat_workspace, contenant vos dossiers players et games.
Ce pyrat_workspace est un projet uv muni d’un environnement virtuel (.venv) dans lequel la bibliothèque pyrat-game est installée.
Lors de son installation, PyRat crée également un petit fichier dans .venv qui ajoute le dossier pyrat_workspace au PYTHONPATH (attention, il ne s’agit pas du PATH, qui sert à trouver les programmes exécutables). Ainsi, les scripts qui lancent les parties (dans games) peuvent importer les classes des joueurs (dans players) via from players.random1 import Random1.
La commande à lancer depuis le dossier projet est uvx --python 3.14 --from pyrat-game pyrat-init, et elle est identique sur tous les systèmes d’exploitation.
Faites bien attention aux remarques ci-dessous. En particulier, dans VSCode, ouvrez cette fois le dossier pyrat_workspace lui-même (et non le dossier projet), car c’est lui qui contient l’environnement virtuel .venv.
Vérifier l’installation de PyRat
Une fois PyRat installé, démarrez VSCode, et ouvrez le dossier pyrat_workspace (celui qui contient pyproject.toml).
Via l’arborescence à gauche, ouvrez le fichier games/sample_game.py, et exécutez-le. Vous devriez constater que VSCode utilise bien l’environnement virtuel du projet, et que la partie se lance.
Information
Vous pouvez aussi lancer une partie sans passer par VSCode : placez-vous dans le dossier pyrat_workspace avec un terminal, et lancez uv run games/sample_game.py.
Préparer le travail en groupe
Avant de démarrer cette activité pratique, vous avez dû former un trinôme. Si ça n’est pas le cas, faites-le maintenant.
À ce stade, chacun(e) d’entre vous a installé PyRat sur sa propre machine et a son propre pyrat_workspace qui fonctionne. À présent, nous allons créer un projet collaboratif, via un dépôt Git sur le GitLab de l’école.
On suppose que vous avez déjà toutes et tous un compte sur ce GitLab, et que vous y avez déposé une clé SSH, comme demandé à l’issue de la séance sur le travail à plusieurs avec Git et GitLab.
Information
Si votre clé n’est pas encore en place, ne bloquez pas la séance là-dessus : prenez l’URL de clonage en HTTPS, qui ne demande que vos identifiants GitLab, et posez votre clé après le cours en suivant la fiche GitLab. Vous pousserez sur ce dépôt jusqu’à la fin du semestre.
Nous allons à présent créer un dépôt Git sur lequel vous pourrez travailler à plusieurs. Bonne nouvelle : votre workspace est déjà un dépôt Git, car pyrat-init en a créé un (en lançant lui-même git init) au moment de l’installation. Il contient également un fichier .gitignore, fourni par PyRat, qui exclut d’office les fichiers qui ne doivent pas être versionnés, comme l’environnement virtuel .venv, les dossiers temporaires __pycache__, ou les fichiers créés par votre éditeur et votre système d’exploitation.
Vérifiez tout de même qu’un dossier .git existe bien à la racine de pyrat_workspace (par exemple en lançant git status depuis ce dossier). Si ce n’est pas le cas, par exemple parce que Git n’était pas installé sur votre machine au moment de lancer pyrat-init (un avertissement s’est alors affiché pendant l’installation), lancez d’abord git init dans pyrat_workspace.
À faire
Choisissez l’un(e) d’entre vous, appelons cette personne le/la « Boss » le temps de cette section, puis suivez les instructions ci-dessous.
Créer le projet
Qui ? Le/La Boss seulement.
Sur https://gitlab-df.imt-atlantique.fr, créez un projet privé du nom de votre choix. Pensez à décocher la case « Initialize repository with a README ».
Copiez ensuite le lien de clonage du projet (en SSH ou en HTTPS). Il vous sera demandé à l’étape suivante.
Publier le workspace
Qui ? Le/La Boss seulement.
Placez-vous dans votre dossier pyrat_workspace avec un terminal, puis effectuez les commandes suivantes (remplacez <url_de_clonage_du_projet> (chevrons <, > inclus) par le lien copié à l’étape précédente) :
# Préparez l'envoi des fichiers du workspacegit add .
git commit -m "Initialisation du dépôt avec le workspace"git branch -M main
# Liez le dossier local au repo GitLabgit remote add origin <url_de_clonage_du_projet>
# Envoyezgit push -u origin main
Si vous avez oublié de décocher la case « Initialize repository with a README », le dépôt GitLab contient déjà un commit, et le push est refusé. Dans ce cas seulement, lancez les commandes suivantes, qui placent le contenu de votre workspace par-dessus ce commit, puis relancez le push. Ne les lancez pas si le dépôt GitLab est vide : git reset --soft origin/main ne fonctionne que si le dépôt distant contient déjà des commits.
# Récupérez le contenu du dépôt distant et remplacez-le par celui du workspacegit fetch origin
git reset --soft origin/main
git commit -m "Remplacement du contenu existant"# Envoyezgit push -u origin main
Rechargez la page du projet sur GitLab : vos fichiers doivent y être. Vérifiez au passage que le dossier .venvn’y est pas, mais que pyproject.toml et uv.lock y sont bien.
Inviter les autres membres et l’enseignant(e)
Qui ? Le/La Boss seulement.
Cliquez sur « Members » dans le menu, puis ajoutez les autres membres du groupe et l’enseignant(e) avec le rôle Maintainer.
Important
N’oubliez pas d’ajouter l’enseignant(e) avec le rôle Maintainer, sinon vos livrables de projet ne seront pas visibles par l’équipe pédagogique et vous n’aurez aucune note.
Récupérer le dépôt
Qui ? Les autres membres du groupe.
Supprimez le dossier pyrat_workspace que vous aviez créé à l’installation. Puis, depuis le dossier projet (ou autre, selon ce que vous avez choisi), clonez celui du groupe. Cela créera un dossier du nom de votre projet, qu’on notera <nom_du_depot> dans les commandes ci-après (à remplacer par le nom du dossier obtenu, chevrons < et > inclus) :
Le dépôt que vous venez de cloner ne contient pas d’environnement virtuel (car il n’était pas sur GitLab grâce au .gitignore). uv sync le recrée sur votre machine, tel que décrit par pyproject.toml et uv.lock, et y installe les dépendances du projet (ici, notamment pyrat-game, ainsi que votre workspace lui-même).
Vérifier que tout marche
Qui ? Tout le monde.
Depuis votre dossier <nom_du_depot>, lancez :
uv run games/sample_game.py
Une partie de PyRat doit se lancer. Si c’est le cas, votre workspace est correctement installé et connecté au dépôt du groupe.
Ouvrez également le menu Source Control de VSCode : votre dépôt Git doit être détecté, et VSCode vous propose de gérer vos add, commit, push et pull directement via son interface.
Information
Pendant tout le projet, quelques commandes suffiront la plupart du temps :
# Récupérez le travail des autres avant de commencer à travaillergit pull
# Enregistrez votre travail et partagez-le avec le groupegit add .
git commit -m "message décrivant vos changements"git push
VSCode permet de faire ces commandes via son interface graphique, notamment via le menu Source Control.
Un premier exemple de travail collaboratif
Dans tout projet informatique, il est important d’avoir un petit fichier qui explique succinctement (ou pas) à quoi sert le projet. On appelle ce fichier un « README », et on y trouve généralement les adresses de contact des auteur(e)s, les instructions d’utilisation du projet, etc.
Sur les dépôts Git, ce fichier s’appelle classiquement README.md, car on rédige son contenu en Markdown, un langage permettant de formater du texte. Si vous avez déjà utilisé des outils comme Discord, (ou Mattermost, Slack, etc.), c’est généralement ce langage qui est utilisé pour mettre du texte en gras, italique, etc.
Par défaut, le README de votre projet contient des informations de base sur ce qu’est un workspace PyRat. Nous allons y ajouter la liste du groupe pour l’exemple.
À faire
Choisissez l’un(e) d’entre vous qui n’était pas le/la Boss, et appelons cet(te) étudiant(e) « Big Boss » (le temps de cette section, ne prenez pas la grosse tête tout de suite). Puis, suivez les instructions ci-dessous.
Compléter le README
Qui ? Le/La Big Boss seulement.
Le/La Big Boss ajoute les noms de ses coéquipier(ère)s dans le fichier README.md, par exemple dans une nouvelle section Students au début de celui-ci (pensez à enlever les chevrons <, > qui indiquent juste que le contenu est à remplacer) :
Il/elle s’arrange pour que le fichier README.md modifié apparaisse sur le dépôt.
Récupérer le fichier modifié
Qui ? Les autres membres du groupe.
Le but est de manipuler Git (via l’interface VSCode ou la ligne de commande) pour que chaque étudiant(e) du groupe ait une vue locale à jour du dépôt dans son workspace.
Correction
Cliquez ici pour afficher la correction.
Le/La Big Boss ajoute les noms de ses coéquipier(ère)s dans le fichier README.md, puis fait un add, un commit, puis un push. Les autres font un pull.
C’est parti !
Se familiariser avec PyRat
À ce stade, tout est prêt pour commencer le projet ! Découvrons à présent en quoi consiste le logiciel PyRat. Avant d’attaquer la création de programmes PyRat, jetez un œil aux liens suivants.
À faire
Parcourez le lien de découverte de PyRat (ci-dessous), et suivez le premier tutoriel sur le site (le tutoriel sur les programmes RandomX). Faites bien attention à aller jusqu’au bout du tutoriel ! On vous y fournit notamment un nouveau script evaluate_random_players.py à placer dans le dossier games, que nous utiliserons par la suite.
Découverte de PyRat : ce lien présente PyRat dans les grandes lignes, à savoir le contenu de votre workspace et de la bibliothèque Python pyrat-game, les éléments de jeu, etc.
Tutoriels de base : sur cette page, nous vous proposons quelques tutoriels à suivre, qui détaillent les programmes PyRat fournis par défaut dans votre workspace, et vous expliquent comment customiser les éléments de jeu.
Votre premier programme PyRat
L’objectif de cette activité pratique est d’écrire vos premiers codes PyRat, c’est-à-dire :
Un joueur, sous la forme d’une classe héritant de Player. On stockera les fichiers décrivant les joueurs dans le dossier players.
Un script, qui lance une partie avec votre joueur. On stockera les fichiers décrivant les scripts dans le dossier games.
Au début de la partie, raye les culs-de-sac de la carte pour ne pas s’y diriger.
Puis, à chaque tour de jeu :
Détermine les cases adjacentes au personnage qui n’ont pas encore été visitées dans la partie. Il en choisit une aléatoirement, et s’y déplace.
Si toutes les cases adjacentes ont déjà été visitées, le personnage choisit une case adjacente au hasard, et s’y déplace.
L’un des problèmes de Random4 est que, si le rat arrive dans une zone du labyrinthe où il a visité toutes les cases voisines, sa stratégie pour en sortir n’est pas optimisée. En effet, le rat choisit une case adjacente au hasard, et s’y déplace, en espérant atteindre une case du labyrinthe voisine de cases pas encore visitées pour améliorer le tour suivant.
On pourrait améliorer cet aspect du programme en gardant en mémoire notre trajectoire, c’est-à-dire la suite de cases parcourues jusqu’au tour en cours. Ainsi, en arrivant sur une case dont on a déjà visité toutes les cases voisines, il suffirait de revenir en arrière le long de la trajectoire parcourue. Si on arrive sur une case dont on a déjà visité tous les voisins, on recommence, etc. À un moment, à force de revenir sur ses pas, le rat finira par arriver sur une case ayant au moins une case voisine inexplorée, et pourra reprendre sa quête de fromage.
Création du joueur
À faire
Faites une copie du fichier random4.py (dans le dossier players), et renommez le nouveau fichier random5.py (dans le même dossier). Faites toutes les modifications nécessaires pour que ce nouveau fichier décrive une classe nommée Random5.
Ne vous préoccupez pas du comportement du joueur pour le moment, on cherche juste à créer un programme utilisable qui ne crashe pas.
Correction
Cliquez ici pour afficher la correction.
Pour le moment, juste copier random4.py en random5.py et renommer la classe. Penser à adapter la doc aussi éventuellement.
Création de la partie
À faire
Créez un fichier visualize_random5.py dans le dossier games de votre pyrat_workspace. Ce fichier doit effectuer les opérations détaillées ci-dessous.
Inspirez-vous des fichiers existants dans le dossier games, ainsi que du tutoriel sur la personnalisation des parties.
Importer la classe Random5 que vous venez de créer.
Créer une partie de PyRat (une instance de la classe Game), contenant 1 morceau de fromage.
Pensez aussi à mettre une valeur élevée à l’argument trace_length pour vérifier plus tard que le rat revient bien sur ses traces.
Instancier un joueur de la classe Random5, et l’ajouter à la partie.
Démarrer la partie.
Correction
Cliquez ici pour afficher la correction.
Le programme devrait ressembler à ça :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport pprint
# PyRat importsfrom pyrat import Game
from players.random5 import Random5
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Instantiate a game with a few arguments game_config = {"mud_percentage": 0.0,
"maze_width": 13,
"maze_height": 10,
"nb_cheese": 1,
"random_seed": 42,
"trace_length": 1000}
# Instantiate a game with specified arguments game = Game(**game_config)
# Instantiate the player player = Random5()
game.add_player(player)
# Start the game stats = game.start()
pprint.pprint(stats)
####################################################################################################################################################################################
Implémentation de la stratégie
À faire
Maintenant que vous avez un script visualize_random5.py qui lance une partie de PyRat avec le joueur Random5, adaptez le code de random5.py pour que le rat suive la stratégie décrite plus haut.
N’hésitez pas à lancer régulièrement votre script visualize_random5.py, par exemple pour afficher la trajectoire parcourue, ou pour vérifier que votre code ne crashe pas.
Vous devriez obtenir un rat qui se comporte comme suit. En particulier, il ne devrait pas aller dans des culs-de-sac (amélioration apportée par Random4), et devrait revenir sur ses pas quand toutes les cases voisines sont déjà visitées.
Correction
Cliquez ici pour afficher la correction.
Voici le diagramme de classes du joueur Random5, qui hérite de Player :
Le programme devrait ressembler à ça :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport random
# PyRat importsfrom pyrat import Player, Maze, GameState, Action
################################################################################################################################### CLASSES ##################################################################################################################################classRandom5 (Player):
"""
*(This class inherits from* ``Player`` *).*
This player is an improvement of the ``Random4`` player.
A limitation of ``Random4`` is that it gets stuck in areas when all neighbors have been visited.
To avoid this, we keep track of the trajectory of the player, and when we are stuck, we go back to the last cell in the trajectory.
"""################################################################################### CONSTRUCTOR ###################################################################################def__init__ ( self,
*args: object,
**kwargs: object
) ->None:
"""
Initializes a new instance of the class.
Here, in addition to the attributes developed in the ``Random4`` player, we also create an attribute for our trajectory.
Args:
args: Arguments to pass to the parent constructor.
kwargs: Keyword arguments to pass to the parent constructor.
"""# Inherit from parent class super().__init__(*args, **kwargs)
# We create an attribute to keep track of visited cells# We will initialize it in the ``preprocessing()`` method to allow the game to be reset# Otherwise, the set would keep the cells visited in previous games self.visited_cells =None# We also create an attribute for the reduced maze self.reduced_maze =None# We create an attribute for the player's trajectory self.trajectory =None################################################################################### PYRAT METHODS ###################################################################################defpreprocessing ( self,
maze: Maze,
game_state: GameState,
) ->None:
"""
*(This method redefines the method of the parent class with the same name).*
This method is called once at the beginning of the game.
Here, we use it to create a reduced maze that contains only the cells that are not dead-ends.
We define a dead-end as a cell that has only one neighbor and does not contain cheese or the player.
Note that this is not the best way to define a dead-end, but it is a simple one.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
"""# Initialize visited cells self.visited_cells = set()
# Reduce the maze my_location = game_state.player_locations[self.get_name()]
self.reduced_maze = self.remove_dead_ends(maze, [my_location] + game_state.cheese)
# Initialize the trajectory self.trajectory = [my_location]
##################################################################################defturn ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
*(This method redefines the method of the parent class with the same name).*
It is called at each turn of the game.
It returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
We also update the set of visited cells at each turn.
Now, we work with the reduced maze to find the next action.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions.
"""# Mark current cell as visited my_location = game_state.player_locations[self.get_name()]
if my_location notin self.visited_cells:
self.visited_cells.add(my_location)
# Add current location to trajectory self.trajectory.append(my_location)
# Return an action action = self.find_next_action(self.reduced_maze, game_state)
return action
################################################################################### OTHER METHODS ###################################################################################deffind_next_action ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
This method returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
Here, the action is chosen randomly among those that don't hit a wall, and that lead to an unvisited cell if possible.
If no such action exists, we come back to the last cell in the trajectory and pop it.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions that leads to a valid neighbor.
"""# Go to an unvisited neighbor in priority my_location = game_state.player_locations[self.get_name()]
neighbors = maze.get_neighbors(my_location)
unvisited_neighbors = [neighbor for neighbor in neighbors if neighbor notin self.visited_cells]
if len(unvisited_neighbors) >0:
neighbor = random.choice(unvisited_neighbors)
# If there is no unvisited neighbor, go back in the trajectory# We pop the last cell in the trajectory, which is the current cell# Then, we pop again to get the previous cell, which is where we want to go backelse:
self.trajectory.pop()
neighbor = self.trajectory.pop()
# Retrieve the corresponding action action = maze.locations_to_action(my_location, neighbor)
return action
##################################################################################defremove_dead_ends ( self,
maze: Maze,
locations_to_keep: list[int]
) -> Maze:
"""
This method removes the dead-ends from the maze, and returns it.
A dead-end is defined as a cell that has only one neighbor and does not contain cheese or the player.
The maze is modified in place: no copy is made, and the returned maze is the same object as the one given as argument.
Args:
maze: An object representing the maze in which the player plays.
locations_to_keep: A list of locations to keep in the reduced maze.
Returns:
The maze given as argument, from which the dead-ends have been removed.
"""# Initialize the reduced maze as the original one# We do not make a copy, as PyRat gives each player its own copy of the maze once per game# Note that this object is the one passed to preprocessing() and to every turn(), so the maze received in turn() will also be reduced updated_maze = maze
# Iteratively remove dead-ends from the maze# We still keep dead ends that contain locations to keep removed_something =Truewhile removed_something:
removed_something =Falsefor vertex in updated_maze.get_vertices():
if len(updated_maze.get_neighbors(vertex)) ==1and vertex notin locations_to_keep:
updated_maze.remove_vertex(vertex)
removed_something =True# Return the updated mazereturn updated_maze
####################################################################################################################################################################################
Évaluation de la stratégie
Maintenant que vous avez une nouvelle stratégie, il faut se demander si elle est vraiment efficace. Pour cela, une approche possible est de la comparer à d’autres méthodes.
À faire
Adaptez le script evaluate_random_players.py fourni à la fin du tutoriel pour ajouter votre joueur Random5 aux quatre autres.
Quelles conclusions tirez-vous ? Discutez-en avec votre enseignant(e) !
Correction
Cliquez ici pour afficher la correction.
Voici une adaptation du script evaluate_random_players.py. Nous prenons un peu d’avance en incluant également les joueurs Random6 et Random7, qui seront demandés dans les sections « Le joueur Random6 » et « Le joueur Random7 » plus bas.
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport tqdm.auto as tqdm
import matplotlib.pyplot as pyplot
import scipy.stats as scstats
# PyRat importsfrom pyrat import Game, GameMode
from players.random1 import Random1
from players.random2 import Random2
from players.random3 import Random3
from players.random4 import Random4
from players.random5 import Random5
from players.random6 import Random6
from players.random7 import Random7
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Determines how many games will be played for each player nb_games =1000# Customize the game elements game_config = {"mud_percentage": 0.0,
"nb_cheese": 1,
"game_mode": GameMode.SIMULATION}
# Run the games for each player class players = [Random1(), Random2(), Random3(), Random4(), Random5(), Random6(), Random7()]
results = {player.get_name(): [] for player in players}
for player in players:
for seed in tqdm.tqdm(range(nb_games), desc=player.get_name()):
# Make the game with given seed game = Game(random_seed=seed, **game_config)
game.add_player(player)
stats = game.start()
# Store the number of turns needed results[player.get_name()].append(stats["turns"])
# Visualization of cumulative curves of numbers of turns taken per program max_turn = max([max(results[player]) for player in results])
pyplot.figure(figsize=(10, 5))
for player in results:
turns = [0] + sorted(results[player]) + [max_turn]
games_completed_per_turn = [len([turn for turn in results[player] if turn <= t]) *100.0/ nb_games for t in turns]
pyplot.plot(turns, games_completed_per_turn, label=player)
pyplot.title("Comparison of turns needed to complete all %d games"% (nb_games))
pyplot.xlabel("Turns per game")
pyplot.ylabel("% of games completed")
pyplot.xscale("log")
pyplot.legend()
pyplot.savefig("evaluate_random_players.png")
# Formal statistics to check if these curves are statistically significantfor i, player_1 in enumerate(results):
for j, player_2 in enumerate(results):
if j > i:
test_result = scstats.mannwhitneyu(results[player_1], results[player_2], alternative="two-sided")
print("Mann-Whitney U test between turns of program '%s' and of program '%s':"% (player_1, player_2), test_result)
####################################################################################################################################################################################
En exécutant ce script, on obtient le graphique suivant :
Et on obtient les résultats suivants pour les tests statistiques :
Mann-Whitney U test between turns of program 'Random1' and of program 'Random2':
MannwhitneyuResult(statistic=np.float64(629444.0), pvalue=np.float64(1.193805368295762e-23))
Mann-Whitney U test between turns of program 'Random1' and of program 'Random3':
MannwhitneyuResult(statistic=np.float64(777997.5), pvalue=np.float64(8.474324515868948e-103))
Mann-Whitney U test between turns of program 'Random1' and of program 'Random4':
MannwhitneyuResult(statistic=np.float64(803477.5), pvalue=np.float64(3.946294369720488e-122))
Mann-Whitney U test between turns of program 'Random1' and of program 'Random5':
MannwhitneyuResult(statistic=np.float64(896372.5), pvalue=np.float64(6.578900822721211e-207))
Mann-Whitney U test between turns of program 'Random1' and of program 'Random6':
MannwhitneyuResult(statistic=np.float64(974073.0), pvalue=np.float64(3.710534971663599e-295))
Mann-Whitney U test between turns of program 'Random1' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(971377.0), pvalue=np.float64(8.137790587846432e-292))
Mann-Whitney U test between turns of program 'Random2' and of program 'Random3':
MannwhitneyuResult(statistic=np.float64(675221.0), pvalue=np.float64(6.101481042673725e-42))
Mann-Whitney U test between turns of program 'Random2' and of program 'Random4':
MannwhitneyuResult(statistic=np.float64(709365.5), pvalue=np.float64(4.059310828048741e-59))
Mann-Whitney U test between turns of program 'Random2' and of program 'Random5':
MannwhitneyuResult(statistic=np.float64(830148.5), pvalue=np.float64(3.563466221925198e-144))
Mann-Whitney U test between turns of program 'Random2' and of program 'Random6':
MannwhitneyuResult(statistic=np.float64(954534.5), pvalue=np.float64(1.642204139083212e-271))
Mann-Whitney U test between turns of program 'Random2' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(950008.5), pvalue=np.float64(3.715566915390973e-266))
Mann-Whitney U test between turns of program 'Random3' and of program 'Random4':
MannwhitneyuResult(statistic=np.float64(543162.0), pvalue=np.float64(0.0008304016572701611))
Mann-Whitney U test between turns of program 'Random3' and of program 'Random5':
MannwhitneyuResult(statistic=np.float64(691584.0), pvalue=np.float64(8.519425633784984e-50))
Mann-Whitney U test between turns of program 'Random3' and of program 'Random6':
MannwhitneyuResult(statistic=np.float64(925934.5), pvalue=np.float64(1.0825651827569589e-238))
Mann-Whitney U test between turns of program 'Random3' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(916785.0), pvalue=np.float64(1.2737098944078e-228))
Mann-Whitney U test between turns of program 'Random4' and of program 'Random5':
MannwhitneyuResult(statistic=np.float64(644933.5), pvalue=np.float64(3.1172422904019286e-29))
Mann-Whitney U test between turns of program 'Random4' and of program 'Random6':
MannwhitneyuResult(statistic=np.float64(916347.0), pvalue=np.float64(3.6742404350271064e-228))
Mann-Whitney U test between turns of program 'Random4' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(905822.5), pvalue=np.float64(7.305777365442017e-217))
Mann-Whitney U test between turns of program 'Random5' and of program 'Random6':
MannwhitneyuResult(statistic=np.float64(891258.5), pvalue=np.float64(9.574270851465981e-202))
Mann-Whitney U test between turns of program 'Random5' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(877888.0), pvalue=np.float64(2.5564688168315526e-188))
Mann-Whitney U test between turns of program 'Random6' and of program 'Random7':
MannwhitneyuResult(statistic=np.float64(468236.5), pvalue=np.float64(0.01379698799921949))
D’après la courbe et les tests, on observe que Random5 prend significativement ($p \ll 0.01$) moins de tours en moyenne que les programmes Random1 à Random4. C’est donc une amélioration pertinente du point de vue des résultats.
Cela dit, on n’a pas mesuré les impacts en termes de temps de calcul et de coût énergétique, donc il faudrait pousser un peu plus les analyses pour voir si c’est à garder.
Pour aller plus loin
Le joueur Random6
Description de la stratégie
Quand Random5 arrive à un croisement amenant à plusieurs cases voisines non visitées, le rat choisit une case aléatoirement parmi celles-ci et s’y dirige.
Imaginons le cas trivial où il n’y a aucun mur. On se dit alors que les directions se rapprochant le plus du fromage (à vol d’oiseau) sont les plus intéressantes. Comme en PyRat on bouge sur une grille, mieux vaut aller vers le nord ou l’est si le fromage est au nord-est. Une stratégie intéressante serait alors de trier les cases voisines non visitées par ordre croissant de distance au fromage, et de privilégier la première.
À faire
Quel est l’impact du nombre de murs sur cette stratégie et son intérêt par rapport à une orientation aléatoire ?
Correction
Cliquez ici pour afficher la correction.
Plus on a de murs, plus il faudra faire des chemins détournés pour aller au fromage. Donc la stratégie deviendra probablement moins efficace.
Implémentation et évaluation de la stratégie
À faire
En vous inspirant de ce que vous avez fait pour Random5, créez un joueur Random6 (et un script dans games pour le tester), qui implémente la stratégie guidée par l’emplacement du fromage.
Vous devriez observer quelque chose comme ça :
Correction
Cliquez ici pour afficher la correction.
Voici le diagramme de classes du joueur Random6, qui hérite de Player :
Le programme devrait ressembler à ça (il n’importe plus le module random, dont il n’a plus besoin : ce ménage est demandé dans la section « Remarques » plus bas) :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### PyRat importsfrom pyrat import Player, Maze, GameState, Action
################################################################################################################################### CLASSES ##################################################################################################################################classRandom6 (Player):
"""
*(This class inherits from* ``Player`` *).*
This player is an improvement of the ``Random5`` player.
A limitation of ``Random5`` is that it gives equal importance to all unvisited neighbors.
However, some may reduce the distance to the cheese, while others may increase it.
"""################################################################################### CONSTRUCTOR ###################################################################################def__init__ ( self,
*args: object,
**kwargs: object
) ->None:
"""
Initializes a new instance of the class.
Args:
args: Arguments to pass to the parent constructor.
kwargs: Keyword arguments to pass to the parent constructor.
"""# Inherit from parent class super().__init__(*args, **kwargs)
# We create an attribute to keep track of visited cells# We will initialize it in the ``preprocessing()`` method to allow the game to be reset# Otherwise, the set would keep the cells visited in previous games self.visited_cells =None# We also create an attribute for the reduced maze self.reduced_maze =None# We create an attribute for the player's trajectory self.trajectory =None################################################################################### PYRAT METHODS ###################################################################################defpreprocessing ( self,
maze: Maze,
game_state: GameState,
) ->None:
"""
*(This method redefines the method of the parent class with the same name).*
This method is called once at the beginning of the game.
Here, we use it to create a reduced maze that contains only the cells that are not dead-ends.
We define a dead-end as a cell that has only one neighbor and does not contain cheese or the player.
Note that this is not the best way to define a dead-end, but it is a simple one.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
"""# Initialize visited cells self.visited_cells = set()
# Reduce the maze my_location = game_state.player_locations[self.get_name()]
self.reduced_maze = self.remove_dead_ends(maze, [my_location] + game_state.cheese)
# Initialize the trajectory self.trajectory = [my_location]
##################################################################################defturn ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
*(This method redefines the method of the parent class with the same name).*
It is called at each turn of the game.
It returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
We also update the set of visited cells at each turn.
Now, we work with the reduced maze to find the next action.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions.
"""# Mark current cell as visited my_location = game_state.player_locations[self.get_name()]
if my_location notin self.visited_cells:
self.visited_cells.add(my_location)
# Add current location to trajectory self.trajectory.append(my_location)
# Return an action action = self.find_next_action(self.reduced_maze, game_state)
return action
################################################################################### OTHER METHODS ###################################################################################deffind_next_action ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
This method returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
Here, the action is chosen by prioritizing unvisited neighbors that reduce the distance to the closest cheese.
If no such action exists, we come back to the last cell in the trajectory and pop it.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions that leads to a valid neighbor.
"""# Go to an unvisited neighbor in priority# We sort the unvisited neighbors by their Euclidean distance to the closest cheese my_location = game_state.player_locations[self.get_name()]
neighbors = maze.get_neighbors(my_location)
unvisited_neighbors = [neighbor for neighbor in neighbors if neighbor notin self.visited_cells]
if len(unvisited_neighbors) >0:
# Find the closest cheese in case there are multiple my_x, my_y = maze.i_to_rc(my_location)
distances_player_cheese = {}
for cheese in game_state.cheese:
cheese_x, cheese_y = maze.i_to_rc(cheese)
distances_player_cheese[cheese] = ((my_x - cheese_x) **2+ (my_y - cheese_y) **2) **0.5 closest_cheese = min(distances_player_cheese, key=distances_player_cheese.get)
closest_cheese_x, closest_cheese_y = maze.i_to_rc(closest_cheese)
# Compute distances to the closest cheese for each unvisited neighbor distances_neighbor_cheese = {}
for neighbor in unvisited_neighbors:
neighbor_x, neighbor_y = maze.i_to_rc(neighbor)
distances_neighbor_cheese[neighbor] = ((neighbor_x - closest_cheese_x) **2+ (neighbor_y - closest_cheese_y) **2) **0.5# Choose the neighbor that reduces the distance to the closest cheese the most neighbor = min(distances_neighbor_cheese, key=distances_neighbor_cheese.get)
# If there is no unvisited neighbor, go back in the trajectory# We pop the last cell in the trajectory, which is the current cell# Then, we pop again to get the previous cell, which is where we want to go backelse:
self.trajectory.pop()
neighbor = self.trajectory.pop()
# Retrieve the corresponding action action = maze.locations_to_action(my_location, neighbor)
return action
##################################################################################defremove_dead_ends ( self,
maze: Maze,
locations_to_keep: list[int]
) -> Maze:
"""
This method removes the dead-ends from the maze, and returns it.
A dead-end is defined as a cell that has only one neighbor and does not contain cheese or the player.
The maze is modified in place: no copy is made, and the returned maze is the same object as the one given as argument.
Args:
maze: An object representing the maze in which the player plays.
locations_to_keep: A list of locations to keep in the reduced maze.
Returns:
The maze given as argument, from which the dead-ends have been removed.
"""# Initialize the reduced maze as the original one# We do not make a copy, as PyRat gives each player its own copy of the maze once per game# Note that this object is the one passed to preprocessing() and to every turn(), so the maze received in turn() will also be reduced updated_maze = maze
# Iteratively remove dead-ends from the maze# We still keep dead ends that contain locations to keep removed_something =Truewhile removed_something:
removed_something =Falsefor vertex in updated_maze.get_vertices():
if len(updated_maze.get_neighbors(vertex)) ==1and vertex notin locations_to_keep:
updated_maze.remove_vertex(vertex)
removed_something =True# Return the updated mazereturn updated_maze
####################################################################################################################################################################################
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport pprint
# PyRat importsfrom pyrat import Game
from players.random6 import Random6
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Instantiate a game with a few arguments game_config = {"mud_percentage": 20.0,
"maze_width": 13,
"maze_height": 10,
"nb_cheese": 1,
"random_seed": 42,
"trace_length": 1000}
# Instantiate a game with specified arguments game = Game(**game_config)
# Instantiate the player player = Random6()
game.add_player(player)
# Start the game stats = game.start()
pprint.pprint(stats)
####################################################################################################################################################################################
À faire
Puis, complétez le script evaluate_random_players.py pour ajouter votre joueur Random6 aux cinq autres. Un avis sur les résultats observés ?
Correction
Cliquez ici pour afficher la correction.
Le script evaluate_random_players.py donné plus haut inclut déjà le joueur Random6.
D’après la courbe et les tests, on observe que Random6 prend significativement ($p \ll 0.01$) moins de tours en moyenne que Random5. C’est donc une amélioration pertinente du point de vue des résultats.
Les cas limites
On se demandait précédemment si le nombre de murs pouvait poser problème.
À faire
Faites varier ce nombre pour évaluer la densité au-delà de laquelle Random6 n’est pas significativement meilleur que Random5. Le plus efficace est de créer un script dans le dossier games pour tester ça dans une boucle qui augmente progressivement la densité de murs.
On avait déjà parlé de significativité dans le tutoriel, n’hésitez pas à relire cette partie.
Correction
Cliquez ici pour afficher la correction.
Voici un script qui permet d’étudier l’impact du pourcentage de murs sur les performances relatives de Random5 et Random6 :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport tqdm.auto as tqdm
import matplotlib.pyplot as pyplot
import scipy.stats as scstats
# PyRat importsfrom pyrat import Game, GameMode
from players.random5 import Random5
from players.random6 import Random6
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Determines how many games will be played for each player nb_games =1000# Define the wall percentages to test wall_percentages = [0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]
# Customize the game elements game_config = {"mud_percentage": 0.0,
"nb_cheese": 1,
"game_mode": GameMode.SIMULATION}
# Run the games for each player class players = [Random5(), Random6()]
results = {walls: {player.get_name(): [] for player in players} for walls in wall_percentages}
for walls in wall_percentages:
print(f"Wall percentage: {walls}%")
for player in players:
for seed in tqdm.tqdm(range(nb_games), desc=player.get_name()):
# Make the game with given seed game = Game(random_seed=seed, wall_percentage=walls, **game_config)
game.add_player(player)
stats = game.start()
# Store the number of turns needed results[walls][player.get_name()].append(stats["turns"])
# Visualization of cumulative curves of numbers of turns taken per program max_turn = max([max(results[walls][player]) for walls in results for player in results[walls]])
pyplot.figure(figsize=(10, 5))
for (i_player, player) in enumerate(results[walls]):
for (i_walls, walls) in enumerate(results):
turns = [0] + sorted(results[walls][player]) + [max_turn]
games_completed_per_turn = [len([turn for turn in results[walls][player] if turn <= t]) *100.0/ nb_games for t in turns]
pyplot.plot(turns,
games_completed_per_turn,
label="%s (walls=%.1f%%)"% (player, walls),
linestyle="-"if i_player ==0else"--",
color=pyplot.cm.viridis(i_walls / (len(results) -1)))
pyplot.title("Comparison of turns needed to complete all %d games"% (nb_games))
pyplot.xlabel("Turns per game")
pyplot.ylabel("% of games completed")
pyplot.xscale("log")
pyplot.legend(bbox_to_anchor=(1.05, 1), loc="upper left", ncol=2)
pyplot.tight_layout()
pyplot.savefig("walls_impact_on_random5_random6.png")
# Plot statistical significance as a function of wall percentage p_values = []
for walls in results:
stat, p_value = scstats.mannwhitneyu(results[walls]["Random5"], results[walls]["Random6"], alternative="greater")
p_values.append(p_value)
pyplot.figure(figsize=(10, 5))
pyplot.plot(wall_percentages, p_values, marker='o')
pyplot.axhline(y=0.01, color='r', linestyle='--', label='Significance threshold (0.01)')
pyplot.title("Statistical significance of Random6 vs Random5")
pyplot.xlabel("Wall percentage")
pyplot.ylabel("p-value (Mann-Whitney U test)")
pyplot.yscale("log")
pyplot.legend()
pyplot.savefig("walls_impact_on_random5_random6_p_values.png")
####################################################################################################################################################################################
On observe les courbes suivantes en sortie, pour 1000 parties jouées par programme et par pourcentage de murs :
Le script affiche également la significativité statistique des différences observées entre les deux programmes en fonction du pourcentage de murs :
On observe que le joueur Random6 est significativement meilleur que Random5 (au seuil de 1%) pour toutes les densités de murs testées, sauf à 100% de murs, où la p-value vaut environ 0.5 : la différence n’est plus du tout significative. L’écart diminue tout de même avec le pourcentage de murs qui augmente.
La densité au-delà de laquelle Random6 n’est plus significativement meilleur que Random5 se situe donc entre 90% et 100% de murs. À 100% de murs, le labyrinthe est un labyrinthe parfait : il n’existe qu’un seul chemin entre deux cases. Une fois les culs-de-sac supprimés, il ne reste que l’unique chemin entre le rat et le fromage : les deux stratégies n’ont plus aucun choix à faire, et jouent exactement les mêmes coups.
Notez que la graine (random_seed) fixe le labyrinthe, mais pas les tirages aléatoires de Random5 : les p-values obtenues varient donc un peu d’une exécution à l’autre, sans changer ces conclusions.
Remarques
Si vous avez suivi toutes les étapes, vous devriez vous rendre compte que votre programme ne comporte plus d’aléatoire du tout !
À faire
Pensez à faire un peu de ménage dans les dépendances de votre fichier random6.py (qui est du coup assez mal nommé) en enlevant import random.
Correction
Cliquez ici pour afficher la correction.
Normalement, on n’a plus besoin de import random dans random6.py, donc on peut l’enlever. Idéalement, on devrait aussi renommer le fichier et la classe pour refléter le fait que ce n’est plus un programme aléatoire.
Une autre remarque est que la distance euclidienne utilisée pour trier les voisins non visités ne prend pas en compte la topologie du labyrinthe, ni la boue. Cela s’observe dans la vidéo plus haut, où on voit bien que la portion de chemin 61 -> 48 -> 47 aurait été plus courte que 61 -> 60 -> 47. Il faudra donc passer à des stratégies plus intelligentes pour trouver des chemins plus courts.
Pour aller encore plus loin
Le joueur Random7
La plupart du temps, le fromage va se trouver en diagonale par rapport au rat (par exemple au nord-ouest). Conceptuellement, dans un labyrinthe sans murs, ça ne change pas grand chose d’aller au nord puis à l’ouest, ou d’aller à l’ouest puis au nord. La boue dans le labyrinthe change toutefois cette équivalence. C’est un phénomène que nous avons décrit dans les remarques sur Random6plus haut.
On pourrait donc améliorer Random6 comme suit : si deux actions réduisent la distance au fromage, on privilégie celle qui amène à la case suivante en un minimum de tours.
À faire
Comme précédemment, créez un joueur Random7 qui implémente cette stratégie, ainsi qu’un script pour lancer une partie avec.
Vous devriez observer quelque chose comme ça :
Correction
Cliquez ici pour afficher la correction.
Voici le diagramme de classes du joueur Random7, qui hérite de Player :
Le programme devrait ressembler à ça :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### PyRat importsfrom pyrat import Player, Maze, GameState, Action
################################################################################################################################### CLASSES ##################################################################################################################################classRandom7 (Player):
"""
*(This class inherits from* ``Player`` *).*
This player is an improvement of the ``Random6`` player.
A limitation of ``Random6`` is that it can enter mud by going north then west, while going west then north would avoid the mud.
We improve this by checking weights leading to cells that reduce the distance to the closest cheese.
"""################################################################################### CONSTRUCTOR ###################################################################################def__init__ ( self,
*args: object,
**kwargs: object
) ->None:
"""
Initializes a new instance of the class.
Args:
args: Arguments to pass to the parent constructor.
kwargs: Keyword arguments to pass to the parent constructor.
"""# Inherit from parent class super().__init__(*args, **kwargs)
# We create an attribute to keep track of visited cells# We will initialize it in the ``preprocessing()`` method to allow the game to be reset# Otherwise, the set would keep the cells visited in previous games self.visited_cells =None# We also create an attribute for the reduced maze self.reduced_maze =None# We create an attribute for the player's trajectory self.trajectory =None################################################################################### PYRAT METHODS ###################################################################################defpreprocessing ( self,
maze: Maze,
game_state: GameState,
) ->None:
"""
*(This method redefines the method of the parent class with the same name).*
This method is called once at the beginning of the game.
Here, we use it to create a reduced maze that contains only the cells that are not dead-ends.
We define a dead-end as a cell that has only one neighbor and does not contain cheese or the player.
Note that this is not the best way to define a dead-end, but it is a simple one.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
"""# Initialize visited cells self.visited_cells = set()
# Reduce the maze my_location = game_state.player_locations[self.get_name()]
self.reduced_maze = self.remove_dead_ends(maze, [my_location] + game_state.cheese)
# Initialize the trajectory self.trajectory = [my_location]
##################################################################################defturn ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
*(This method redefines the method of the parent class with the same name).*
It is called at each turn of the game.
It returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
We also update the set of visited cells at each turn.
Now, we work with the reduced maze to find the next action.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions.
"""# Mark current cell as visited my_location = game_state.player_locations[self.get_name()]
if my_location notin self.visited_cells:
self.visited_cells.add(my_location)
# Add current location to trajectory self.trajectory.append(my_location)
# Return an action action = self.find_next_action(self.reduced_maze, game_state)
return action
################################################################################### OTHER METHODS ###################################################################################deffind_next_action ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
This method returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
Here, the action is chosen by prioritizing unvisited neighbors that reduce the distance to the closest cheese.
If no such action exists, we come back to the last cell in the trajectory and pop it.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions that leads to a valid neighbor.
"""# Go to an unvisited neighbor in priority# We sort the unvisited neighbors by their Euclidean distance to the closest cheese my_location = game_state.player_locations[self.get_name()]
neighbors = maze.get_neighbors(my_location)
unvisited_neighbors = [neighbor for neighbor in neighbors if neighbor notin self.visited_cells]
if len(unvisited_neighbors) >0:
# Find the closest cheese in case there are multiple my_x, my_y = maze.i_to_rc(my_location)
distances_player_cheese = {}
for cheese in game_state.cheese:
cheese_x, cheese_y = maze.i_to_rc(cheese)
distances_player_cheese[cheese] = ((my_x - cheese_x) **2+ (my_y - cheese_y) **2) **0.5 closest_cheese = min(distances_player_cheese, key=distances_player_cheese.get)
closest_cheese_x, closest_cheese_y = maze.i_to_rc(closest_cheese)
# Compute distances to the closest cheese for each unvisited neighbor distances_neighbor_cheese = {}
for neighbor in unvisited_neighbors:
neighbor_x, neighbor_y = maze.i_to_rc(neighbor)
distances_neighbor_cheese[neighbor] = ((neighbor_x - closest_cheese_x) **2+ (neighbor_y - closest_cheese_y) **2) **0.5# Among neighbors that reduce the distance to the closest cheese, choose the closest one from current location# If no neighbor reduces the distance to the closest cheese, we do as Random6 valid_neighbors = {neighbor: maze.get_weight(my_location, neighbor) for neighbor in unvisited_neighbors if distances_neighbor_cheese[neighbor] < distances_player_cheese[closest_cheese]}
if len(valid_neighbors) >0:
neighbor = min(valid_neighbors, key=valid_neighbors.get)
else:
neighbor = min(distances_neighbor_cheese, key=distances_neighbor_cheese.get)
# If there is no unvisited neighbor, go back in the trajectory# We pop the last cell in the trajectory, which is the current cell# Then, we pop again to get the previous cell, which is where we want to go backelse:
self.trajectory.pop()
neighbor = self.trajectory.pop()
# Retrieve the corresponding action action = maze.locations_to_action(my_location, neighbor)
return action
##################################################################################defremove_dead_ends ( self,
maze: Maze,
locations_to_keep: list[int]
) -> Maze:
"""
This method removes the dead-ends from the maze, and returns it.
A dead-end is defined as a cell that has only one neighbor and does not contain cheese or the player.
The maze is modified in place: no copy is made, and the returned maze is the same object as the one given as argument.
Args:
maze: An object representing the maze in which the player plays.
locations_to_keep: A list of locations to keep in the reduced maze.
Returns:
The maze given as argument, from which the dead-ends have been removed.
"""# Initialize the reduced maze as the original one# We do not make a copy, as PyRat gives each player its own copy of the maze once per game# Note that this object is the one passed to preprocessing() and to every turn(), so the maze received in turn() will also be reduced updated_maze = maze
# Iteratively remove dead-ends from the maze# We still keep dead ends that contain locations to keep removed_something =Truewhile removed_something:
removed_something =Falsefor vertex in updated_maze.get_vertices():
if len(updated_maze.get_neighbors(vertex)) ==1and vertex notin locations_to_keep:
updated_maze.remove_vertex(vertex)
removed_something =True# Return the updated mazereturn updated_maze
####################################################################################################################################################################################
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport pprint
# PyRat importsfrom pyrat import Game
from players.random7 import Random7
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Instantiate a game with a few arguments game_config = {"mud_percentage": 20.0,
"maze_width": 13,
"maze_height": 10,
"nb_cheese": 1,
"random_seed": 42,
"trace_length": 1000}
# Instantiate a game with specified arguments game = Game(**game_config)
# Instantiate the player player = Random7()
game.add_player(player)
# Start the game stats = game.start()
pprint.pprint(stats)
####################################################################################################################################################################################
À faire
Puis, complétez le script evaluate_random_players.py pour l’ajouter aux autres. Est-ce que cet ajout améliore significativement les performances moyennes par rapport à Random6 ?
Correction
Cliquez ici pour afficher la correction.
Le script evaluate_random_players.py donné plus haut inclut déjà le joueur Random7.
D’après la courbe et les tests, on observe une petite dégradation, mais le test de Mann-Whitney ne permet pas de conclure que c’est significativement moins bon ($p \approx 0.014 > 0.01$).
Attention toutefois, cette évaluation est faite sans boue ("mud_percentage": 0.0 dans le script). Dans ce cas, toutes les cases voisines sont atteintes en un seul tour, et le critère du nombre minimum de tours ne permet pas de départager les voisins qui réduisent la distance au fromage. Random7 prend alors le premier d’entre eux, sans privilégier celui qui la réduit le plus comme le fait Random6, ce qui explique la petite dégradation observée. Cette évaluation ne mesure donc pas ce qui fait l’intérêt de Random7, et ça vaudrait le coup de tester ce que ça donne avec de la boue (et moins de murs), il est possible que ça change les conclusions.
Enfin, nous verrons plus bas, avec un test statistique plus adapté (le test de Wilcoxon), que la différence entre Random6 et Random7 est en fait bien significative ($p \approx 10^{-7}$).
Suivre le mur
On dit parfois que quand on est perdu dans un labyrinthe, il suffit de poser une main sur un mur, et d’avancer sans jamais décoller sa main. On teste ?
À faire
Prenez quelques minutes pour essayer d’imaginer les cas où cette stratégie peut fonctionner, pour partir d’une case et arriver au morceau de fromage.
Une fois que vous avez identifié ce qui marche, créez un script dans games pour créer ce contexte, puis créez un joueur pour réaliser cette stratégie.
Information utile : vous pouvez fixer la position de départ du joueur et du fromage. Consultez la doc !
Correction
Cliquez ici pour afficher la correction.
Pour que la stratégie fonctionne, il faut qu’il existe un mur continu entre la position initiale et le fromage. C’est par exemple le cas si on se place sur le bord du labyrinthe et que le fromage touche le bord.
Au contraire, on aura des soucis si le fromage est au milieu du labyrinthe, sans mur adjacent. Idem pour le joueur qui, s’il commence sans mur adjacent ou touchant un mur qui n’amène pas au fromage, ne pourra jamais le trouver.
Testons en forçant les positions des joueurs et du fromage. Voici le diagramme de classes d’un joueur WallFollower, qui suit le mur à sa droite :
Et voici son code, ainsi qu’un script de jeu qui crée un labyrinthe sans trous (pour garantir que la case 0 existe), et qui place le joueur en bas à droite et le fromage en haut à gauche :
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### PyRat importsfrom pyrat import Player, Maze, GameState, Action
################################################################################################################################### CLASSES ##################################################################################################################################classWallFollower (Player):
"""
*(This class inherits from* ``Player`` *).*
This player follows a wall by always keeping its right hand on the wall.
"""################################################################################### CONSTRUCTOR ###################################################################################def__init__ ( self,
*args: object,
**kwargs: object
) ->None:
"""
Initializes a new instance of the class.
Args:
args: Arguments to pass to the parent constructor.
kwargs: Keyword arguments to pass to the parent constructor.
"""# Inherit from parent class super().__init__(*args, **kwargs)
# We remember the previous action performed self.previous_action = Action.NOTHING
################################################################################### PYRAT METHODS ###################################################################################defpreprocessing ( self,
maze: Maze,
game_state: GameState,
) ->None:
"""
*(This method redefines the method of the parent class with the same name).*
This method is called once at the beginning of the game.
Here, we initialize the previous action so that an adjacent wall is on our right.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
"""# By default (no adjacent wall), we arbitrarily consider that we were heading north self.previous_action = Action.NORTH
# For each direction where a wall may be, the heading that puts this wall on our right heading_for_wall_on_right = {Action.EAST: Action.NORTH,
Action.SOUTH: Action.EAST,
Action.WEST: Action.SOUTH,
Action.NORTH: Action.WEST}
# Initialize last action so that the first adjacent wall found is on our right my_location = game_state.player_locations[self.get_name()]
for wall_direction, heading in heading_for_wall_on_right.items():
if self.action_hits_wall(maze, my_location, wall_direction):
self.previous_action = heading
break##################################################################################defturn ( self,
maze: Maze,
game_state: GameState,
) -> Action:
"""
*(This method redefines the method of the parent class with the same name).*
It is called at each turn of the game.
It returns an action to perform among the possible actions, defined in the ``Action`` enumeration.
We return an action that allows to keep the wall on the right.
Args:
maze: An object representing the maze in which the player plays.
game_state: An object representing the state of the game.
Returns:
One of the possible actions.
"""# Actions order depends on the last action performed actions_order = []
if self.previous_action == Action.NORTH:
actions_order = [Action.EAST, Action.NORTH, Action.WEST, Action.SOUTH]
elif self.previous_action == Action.EAST:
actions_order = [Action.SOUTH, Action.EAST, Action.NORTH, Action.WEST]
elif self.previous_action == Action.SOUTH:
actions_order = [Action.WEST, Action.SOUTH, Action.EAST, Action.NORTH]
elif self.previous_action == Action.WEST:
actions_order = [Action.NORTH, Action.WEST, Action.SOUTH, Action.EAST]
# Test actions in an order that allows to keep the wall on the right my_location = game_state.player_locations[self.get_name()]
for action in actions_order:
ifnot self.action_hits_wall(maze, my_location, action):
self.previous_action = action
return action
# If all actions hit a wall, we don't movereturn Action.NOTHING
################################################################################### OTHER METHODS ###################################################################################defaction_hits_wall ( self,
maze: Maze,
current_location: int,
action: Action,
) -> bool:
"""
This method indicates whether the given action from the current location hits a wall.
Args:
maze: An object representing the maze in which the player plays.
current_location: The current location of the player.
action: The action to perform.
Returns:
True if the action hits a wall, False otherwise.
"""# Check where the action would lead current_location_row, current_location_col = maze.i_to_rc(current_location)
target_location_row, target_location_col = current_location_row, current_location_col
if action == Action.NORTH:
target_location_row -=1elif action == Action.SOUTH:
target_location_row +=1elif action == Action.WEST:
target_location_col -=1elif action == Action.EAST:
target_location_col +=1# If the target exists, check if we can go thereif maze.rc_exists(target_location_row, target_location_col):
target_location = maze.rc_to_i(target_location_row, target_location_col)
returnnot maze.has_edge(current_location, target_location)
# Target does not exist, so we hit a wallreturnTrue####################################################################################################################################################################################
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport pprint
# PyRat importsfrom pyrat import Game, StartingLocation
from players.wall_follower import WallFollower
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Instantiate a game with a few arguments game_config = {"mud_percentage": 0.0,
"wall_percentage": 70.0,
"cell_percentage": 100.0,
"fixed_cheese": [0],
"maze_width": 13,
"maze_height": 10,
"random_seed": 42,
"trace_length": 1000}
# Instantiate a game with specified arguments game = Game(**game_config)
# Instantiate the player player = WallFollower()
game.add_player(player, location=StartingLocation.BOTTOM_RIGHT)
# Start the game stats = game.start()
pprint.pprint(stats)
####################################################################################################################################################################################
Vous devriez observer un comportement comme celui-ci :
Factoriser tout ça
Maintenant que vous disposez de 7 programmes aléatoires, vous devez vous apercevoir qu’il y a un certain nombre de redondances dans vos codes. En particulier, la fonction remove_dead_ends(...) introduite à partir de Random4 apparaît probablement dans tous les fichiers développés par la suite, vu que ce pré-traitement est relativement indépendant de la stratégie de déplacement.
Maintenant, imaginons que vous découvriez une erreur dans le code de cette fonction. Pour la corriger, il va falloir fouiller dans tous vos fichiers pour changer toutes les copies de remove_dead_ends(...). C’est problématique, car vous allez perdre du temps, et peut-être introduire de nouvelles erreurs sans faire exprès…
À faire
Prenez un moment pour réfléchir aux contenus de vos programmes aléatoires. Comment pourriez-vous exploiter le paradigme orienté objet pour mieux organiser tout ça ? Est-ce que votre nouvelle organisation permet également de factoriser des éléments développés pour le programme qui suit le mur ?
Correction
Cliquez ici pour afficher la correction.
Pour mieux organiser :
Créer une classe RandomPlayer, si possible abstraite (hériter de abc.ABC), et y mettre les éléments communs aux programmes aléatoires.
Changer la relation d’héritage des programmes 1 à 7 pour hériter de RandomPlayer.
Comme la simplification de labyrinthe par remove_dead_ends() est plus liée au labyrinthe qu’au joueur, ça pourrait être bien de définir une classe MazeWrapper.
Cette classe aurait un attribut maze initialisé avec ce qui est reçu lors de l’appel de preprocessing() (pas un héritage, car le maze reçu par preprocessing est une instance d’une classe enfant de Maze, et il y en a plusieurs possibles).
Ensuite, mettre remove_dead_ends() dans MazeWrapper et adapter les codes.
Et pour suivre le mur :
La méthode action_hits_wall() de WallFollower ne dépend que du labyrinthe : elle a donc elle aussi sa place dans MazeWrapper.
Enfin, ça pourrait valoir le coup de créer une classe MyPlayer qui agrège les éléments communs à tous les programmes développés en cours.
Par exemple, c’est elle qui peut créer le MazeWrapper dans preprocessing(), et proposer une méthode get_my_location() pour éviter de réécrire game_state.player_locations[self.get_name()] partout.
Pas dit que ça serve tout de suite, mais peut-être plus tard, si on définit des trucs communs à tous les joueurs.
En reprenant tout, ça donne le diagramme de classes UML suivant :
Pour lire ce diagramme :
Chaque boîte est une classe, avec trois compartiments : son nom, ses attributs, et ses méthodes.
Un nom en italique désigne une classe abstraite, et une méthode en italique est une méthode que les classes enfants doivent implémenter.
Le symbole devant chaque attribut ou méthode indique qui est censé l’utiliser : + pour public, # pour protégé (destiné aux classes enfants), et - pour privé.
Une ligne avec un triangle creux est un héritage : par exemple, RandomPlayer est un MyPlayer.
Une ligne avec un losange plein est une composition : MyPlayer crée et possède son MazeWrapper.
Une ligne avec un losange creux est une agrégation : MazeWrapper encapsule un Maze qu’il reçoit, sans en hériter.
L’étiquette à l’autre bout donne le nombre d’objets et l’attribut qui les contient.
Les classes en orange sont celles de la bibliothèque PyRat, et celles en vert sont celles que vous écrivez.
Dans cette organisation, RandomPlayer.turn() marque la case courante comme visitée, puis appelle find_next_action() sur le labyrinthe de maze_wrapper (simplifié ou non).
Les classes enfants n’ont alors plus qu’à redéfinir ce qui les distingue : Random4 simplifie le labyrinthe dans preprocessing(), et Random5 à Random7 y initialisent aussi leur trajectoire, qu’ils mettent à jour dans turn() avant d’appeler celle de RandomPlayer.
Un test statistique plus adapté
Pour comparer vos programmes, le script evaluate_random_players.py utilise le test de Mann-Whitney. Ce test compare deux échantillons indépendants : il ne tient pas compte du fait que deux résultats proviennent ou non de la même partie.
Or, dans ce script, tous les joueurs jouent les mêmes parties : la $i$-ème partie de chaque joueur est lancée avec la même random_seed, et se déroule donc dans le même labyrinthe. Les résultats sont donc appariés. Certains labyrinthes sont plus difficiles que d’autres (fromage loin du rat, beaucoup de murs, etc.), et cette difficulté joue sur tous les joueurs à la fois. En comparant les joueurs labyrinthe par labyrinthe, on élimine cette source de variation, et on peut détecter des différences plus fines.
À faire
Renseignez-vous sur le test des rangs signés de Wilcoxon, qui est la version appariée du test de Mann-Whitney (fonction scipy.stats.wilcoxon).
Adaptez votre script pour calculer les deux tests sur chaque paire de joueurs, ainsi que la proportion de labyrinthes sur lesquels un joueur fait mieux que l’autre. Les conclusions changent-elles ?
Correction
Cliquez ici pour afficher la correction.
Voici un script qui joue les mêmes parties que evaluate_random_players.py, et compare les deux tests.
Attention, la graine fixe le labyrinthe, pas les tirages aléatoires des joueurs Random1 à Random5 : leurs p-values varient d’une exécution à l’autre (c’est pourquoi elles diffèrent de celles affichées plus haut par evaluate_random_players.py), seules celles de Random6 et Random7 (déterministes) sont reproductibles.
#################################################################################################################################### INFO ####################################################################################################################################"""
This program is a solution to the practical session 1 of the PyRat project:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/session1/practical/
It plays the same games as ``evaluate_random_players.py``, and compares two statistical tests on the results:
* The Mann-Whitney U test, which assumes that the two samples are independent.
* The Wilcoxon signed-rank test, which assumes that the two samples are paired.
Since all players play the same mazes (same random seeds), results are paired: the i-th game of a player can be compared to the i-th game of another one.
"""################################################################################################################################### IMPORTS ################################################################################################################################### External importsimport tqdm.auto as tqdm
import scipy.stats as scstats
# PyRat importsfrom pyrat import Game, GameMode
from players.random1 import Random1
from players.random2 import Random2
from players.random3 import Random3
from players.random4 import Random4
from players.random5 import Random5
from players.random6 import Random6
from players.random7 import Random7
################################################################################################################################### SCRIPT ###################################################################################################################################if __name__ =="__main__":
# Determines how many games will be played for each player nb_games =1000# Customize the game elements game_config = {"mud_percentage": 0.0,
"nb_cheese": 1,
"game_mode": GameMode.SIMULATION}
# Run the games for each player class# The i-th game of each player is played with the same random seed, hence in the same maze players = [Random1(), Random2(), Random3(), Random4(), Random5(), Random6(), Random7()]
results = {player.get_name(): [] for player in players}
for player in players:
for seed in tqdm.tqdm(range(nb_games), desc=player.get_name()):
# Make the game with given seed game = Game(random_seed=seed, **game_config)
game.add_player(player)
stats = game.start()
# Store the number of turns needed results[player.get_name()].append(stats["turns"])
# Compare both tests on each pair of players print("%-20s%-25s%-25s%s"% ("Players", "Mann-Whitney p-value", "Wilcoxon p-value", "Games won by the first player"))
for i, player_1 in enumerate(results):
for j, player_2 in enumerate(results):
if j > i:
# Mann-Whitney ignores which game each result comes from mann_whitney = scstats.mannwhitneyu(results[player_1], results[player_2], alternative="two-sided")
# Wilcoxon works on the differences between results obtained on the same maze wilcoxon = scstats.wilcoxon(results[player_1], results[player_2], alternative="two-sided")
# Share of mazes where the first player needs fewer turns than the second one nb_won = len([1for turns_1, turns_2 in zip(results[player_1], results[player_2]) if turns_1 < turns_2])
# Report print("%-20s%-25.2e%-25.2e%.1f%%"% (player_1 +" / "+ player_2, mann_whitney.pvalue, wilcoxon.pvalue, 100.0* nb_won / nb_games))
####################################################################################################################################################################################
Quand deux joueurs sont très différents (par exemple Random1 et Random6), les deux tests concluent sans ambiguïté. Comparer des p-values aussi minuscules n’aurait d’ailleurs pas de sens : elles disent toutes les deux que la différence est certaine.
Quand deux joueurs sont proches, le test de Wilcoxon est nettement plus sensible. Pour Random4 et Random5, la p-value passe de $10^{-16}$ à $10^{-35}$. Surtout, pour Random6 et Random7, le test de Mann-Whitney donne $p \approx 0.014$, ce qui n’est pas significatif au seuil de 1% utilisé plus haut, alors que le test de Wilcoxon donne $p \approx 10^{-7}$ : la différence est bien réelle. Avec le test de Mann-Whitney, elle était masquée par les écarts de difficulté entre labyrinthes, que le test de Wilcoxon élimine en comparant les joueurs sur les mêmes labyrinthes.
Un test dit seulement si une différence existe, pas si elle est importante. La dernière colonne complète cette information : par exemple, Random6 ne fait mieux que Random7 que sur 23,8% des labyrinthes (sur les autres, les deux joueurs sont à égalité, ou Random7 fait mieux).
Attention, un test apparié n’est valable que si les résultats sont vraiment appariés. Ici, c’est le cas parce que la $i$-ème partie de chaque joueur utilise la même random_seed. Si chaque joueur jouait sur des labyrinthes différents, il faudrait revenir au test de Mann-Whitney.