Dans ce TP nous allons décrire le comportement d'un agent se déplaçant aléatoirement dans un environnement très simple.
Pour établir développer des algorithmes d’apprentissage par renforcement, il est nécessaire de caractériser deux entités:
Nous allons regarder comment programmer ces deux entités pour pouvoir réaliser des simulations simples.
Dans un projet pycharm
, vous commencerez par développer deux classes : la classe Agent
et la classe Environnement
.
On considère ici que les simulations se déroulent sur un nombre limité de pas de temps (ici 10).
Voici le constructeur :
class Environment: def __init__(self): self.state = 0 self.steps_left = 10
Dans le cas général, l'environnement initialise également son état. Dans ce cas simple, l'état est un simple compteur qui limite le nombre de pas de temps.
L'état de l'environnement est lu à l'aide de la méthode get_observation()
. Il s'agit habituellement d'une fonction de l'état courant (la mesure). On suppose ici que la méthode retourne directement l'état courant (environnement totalement observable):
def get_observation(self): return self.state
La méthode get_actions()
permet à l'agent de connaître la liste des actions qu'il peut exécuter. Normalement, le jeu d'actions peut changer au cours du temps, certaines actions devenant impossibles dans certains état. Ici seules deux actions sont possibles, encodées par les entiers 0
' et 1
.
def get_actions(self): return [0, 1]
La méthode is_done()
signale la fin de l'épisode (atteinte lorsque le compteur vaut 0).
def is_done(self): return self.steps_left == 0
Enfin, la méthode action()
fait évoluer l'état de l'environnement en fonction des actions de l'agent. Dans le cas général, la méthode modifie l'état en fonction de l'action passée en paramètre, et retourne le signal de récompense. De plus, le nombre de pas est mis à jour afin que la simulation s'arrête lorsque la limite est atteinte.
def action(self, action): if self.is_done(): raise Exception("Game is over") self.steps_left -= 1 return random.random()
La classe Agent est plus simple et ne contient que deux méthodes :
Voici le constructeur. Il contient un compteur qui conserve le nombre total e récompenses accumulées.
class Agent: def __init__(self): self.total_reward = 0.0
La méthode de mise à jour prend comme argument une instance d l'environnement et effectue les opérations suivantes :
def step(self, env): current_obs = env.get_observation() actions = env.get_actions() reward = env.action(random.choice(actions)) self.total_reward += reward
Créez maintenant un programme principal qui crée les deux classes et exécute un épisode :
env = Environment() agent = Agent() while not env.is_done(): agent.step(env) print("Total reward got: %.4f" % agent.total_reward)
Faites tourner cet environnement plusieurs fois et vérifiez que la somme des récompenses obtenues varie à chaque fois.
Les mondes-grilles (Grid worlds) permettent de simuler de petits labyrinthes dans lesquels les actions de l'agent se limitent souvent à des déplacements dans les quatre directions cardinales (Nord, Sud, Est et Ouest), un peu comme dans le jeu Pacman
. Ils ont l'avantage de présenter un nombre limité d'états et de traiter les problèmes d'apprentissage à l'aide de tables de correspondance (Look-up Tables). Les cases contiennent des récompenses ou des pièges qui doivent être attrapés ou évités par l'agent.
Nous considérons ici un labyrinthe extrêmement simple à 8 cases :
S={1,2,3,4,5,6,7,8}
A={N,S,E,W}
1
, seules les actions E
et S
sont autorisées.
Nous allons modifier pas à pas le squelette d'agent et d'environnement précédent pour pouvoir simuler les déplacements d'un agent sur un tel monde.
L'évolution de l'environnement dépend à la fois de l'état courant et de l'action choisie. Ainsi si l'état courant est 3
et l'action est "E"
alors l'état prend la valeur 4
et la récompense est 0
.
action()
.next = { 1 : {"S" : 6, "E" : 2}, 2 : {"W" : 1, "E" : 3}, 3 : {"W" : 2, "S" : 7, "E" : 4}, 4 : {"W" : 3, "E" : 5}, 5 : {"W" : 4, "S" : 8}, 6 : {"N" : 1}, 7 : {"N" : 3}, 8 : {"N" : 5} }
ainsi next[1]["S"]
vaut 6 (l'agent se retrouve dans la case 6 s'il part de la case 1 dans la direction sud).
action()
. Elle n'est plus aléatoire mais doit être lue dans un dictionnaire en fonction de la valeur de l'état:
reward = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: -10, 7: 10, 8: -10}
def __init__(self): self.state = random.randint(1,5) self.steps_left = 10
get_actions()
prend en compte la valeur de l'état (self.state
) pour déterminer le jeu d'actions possibles à partir de la table de correspondance next
.Pour l'agent, pas de changement! Il continue de choisir ses actions au hasard.
Dans le programme principal, effectuer un total de 100 simulations et calculer la moyenne et l'écart-type de la récompense totale.
Afin de rendre notre agent un peu plus "intelligent", nous allons calculer une "fonction de valeur" qui estime le caractère "propice" ou "funeste" d'un état à partir de la récompense cumulée moyenne obtenue à partir de cet état.
Si st est l'état à l'instant t : V(st)=E(T∑t′=trt′) avec T l'instant final (fin de l'épisode)
Il est nécessaire pour cela d'ajouter à l'agent une liste les états parcourus au cours de l'épisode. Cette liste est appelée la trace
.
Ainsi, le constructeur de l'agent devient :
class Agent: def __init__(self): self.total_reward = 0.0 self.trace = []
Cette trace doit être mise à jour à chaque itération pour obtenir en fin d'épisode la liste de tous les états parcourus.
Pour calculer (de façon simplifiée) la fonction de valeur, nous utiliserons l'algorithme suivant :
V = {1 :0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0} nb_visites = {1 :0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0} total_reward_sum = {1 :0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0} pour i de 1 à N: exécuter un épisode pour tout s dans agent.trace : nb_visites[s] += 1 total_reward_sum[s] += agent.total_reward pour tout s dans V: V[s] = total_reward_sum[s] / nb_visites[s]
Pour calculer la fonction de valeur, on utilisera N = 100
Pour améliorer sa récompense moyenne, l'agent va maintenant prendre en compte la valeur de l'état futur pour choisir l'action, autrement dit, si st est l'état courant : at=argmax a∈AV(next(st,a))
step_valeur()
dans laquelle l'action est choisie selon la fonction de valeur (et non plus aléatoirement)def step_valeur(self, env, V): ...
Le calcul exact de la fonction de valeur nécessite de mémoriser l'historique complet des récompenses. Au lieu de conserver la somme des récompenses, on conserve la liste des récompenses obtenues.
class Agent: def __init__(self): self.rewards = [] self.trace = []
et la mise à jour se fait en ajoutant les récompenses en fin de liste :
def step(self, env): ... self.rewards += [reward]
En fin de simulation, la valeur cumulée est calculée séparément pour chaque état rencontré:
total_rewards_sum
Calculer à nouveau la fonction de valeur et comparer la politique obtenue à la politique précédente.