Table des matières

Apprentissage par renforcement : premiers pas

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.

Structure du code

Dans un projet pycharm, vous commencerez par développer deux classes : la classe Agent et la classe Environnement.

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.

Dans cet exemple simple,
  • l'action n'est pas prise en compte et l'état n'évolue pas.
  • la récompense est tirée au hasard.
def action(self, action):
    if self.is_done():
        raise Exception("Game is over")
    self.steps_left -= 1
    return random.random()  

La classe Agent

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
Ici bien sûr, l'agent ignore l’observation et se contente de choisir une action au hasard.

Programme principal

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.

Simulation d'un Monde-grille

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 :

$$\mathcal{S} = \{1, 2, 3, 4, 5, 6, 7, 8\}$$

Sur le dessin ci-dessus, l'agent est dans la case 3 et l'état de l’environnement vaut donc 3.

$$\mathcal{A} = \{N, S, E, W\}$$

Attention cependant, toutes les actions ne sont pas autorisées dans toutes les positions. Ainsi, à la position 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'Environnement

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.

Plutôt que d'écrire une suite de tests ifelse, on utilisera une table de correspondance sous la forme d'un dictionnaire (à la manière d'un graphe):
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).

La récompense est la valeur qui doit être retournée par la méthode 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 

L'agent

Pour l'agent, pas de changement! Il continue de choisir ses actions au hasard.

Simulation

Dans le programme principal, effectuer un total de 100 simulations et calculer la moyenne et l'écart-type de la récompense totale.

Pour bien comprendre le comportement de l'agent, pensez à afficher la valeur de l'état, de l'action choisie et de la récompense à chaque pas de temps.

Calcul d'une fonction de valeur

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 $s_t$ est l'état à l'instant $t$ : $$V(s_t) = E(\sum_{t'=t}^T r_{t'})$$ 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

Politique guidée par la valeur

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 $s_t$ est l'état courant : $$ a_t = \underset{a \in \mathcal{A}}{\text{argmax }} V(\text{next}(s_t, a))$$

def step_valeur(self, env, V):
    ...

Améliorations

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é:

Calculer à nouveau la fonction de valeur et comparer la politique obtenue à la politique précédente.