Découvrez l'apprentissage par renforcement
Un robot doit traverser un lac gelé pour atteindre un trésor, sans tomber dans les trous. Personne ne lui fournit d’exemples étiquetés du type « dans cette case, il fallait aller à droite » : il ne reçoit qu’un signal, une récompense quand il atteint le trésor, et rien du tout sinon. Comment peut-il en déduire, case par case, la bonne décision ? C’est tout le sujet de l’apprentissage par renforcement (reinforcement learning, RL).
Dans ce chapitre, vous allez poser le cadre mathématique qui sert de socle à tout le cours : le processus de décision markovien. Notre fil rouge sera ce lac gelé, qui existe sous le nom de FrozenLake dans la bibliothèque Python gymnasium et que nous avons étudié en TP.
Un agent, un environnement, une récompense
Section intitulée « Un agent, un environnement, une récompense »L’apprentissage par renforcement met en scène deux acteurs qui dialoguent en boucle :
- l’agent est celui qui décide (le robot, un programme de jeu, un système de recommandation) ;
- l’environnement est tout le reste : le lac, ses trous, les lois qui le gouvernent.
À chaque instant , l’agent observe l’état dans lequel il se trouve, choisit une action , puis l’environnement lui renvoie une récompense (un nombre réel) et un nouvel état . Et on recommence.
Qu’est-ce qui distingue ce cadre de l’apprentissage supervisé ? Trois choses.
- Pas de bonne réponse fournie. La récompense dit si le résultat est bon, pas quelle action il aurait fallu prendre.
- La récompense peut être très tardive. Au lac gelé, seule la dernière action (celle qui atteint le trésor) rapporte quelque chose. Les treize pas qui l’ont précédée n’ont rien rapporté sur le moment, et pourtant ils étaient indispensables. Attribuer le mérite aux bonnes décisions passées, c’est le problème de l’attribution du crédit (credit assignment).
- Les données dépendent des décisions. Ce que l’agent observe demain dépend de ce qu’il fait aujourd’hui. Un agent qui ne va jamais à droite ne saura jamais ce qu’il y a à droite.
Le lac gelé, notre fil rouge
Section intitulée « Le lac gelé, notre fil rouge »FrozenLake est une grille de 8 cases sur 8. Voici la carte utilisée en TP :
S F F F F F F FF F F F F F F FF F F H F F F FF F F F F H F FF F F H F F F FF H H F F F H FF H F F H F H FF F F H F F F G- S (start) : la case de départ, en haut à gauche ;
- F (frozen) : de la glace sûre ;
- H (hole) : un trou, l’épisode s’arrête et l’agent a perdu ;
- G (goal) : l’objectif, en bas à droite, qui rapporte une récompense de .
Toutes les autres transitions rapportent 0. L’agent dispose de 4 actions, numérotées comme dans gymnasium : 0 pour gauche, 1 pour bas, 2 pour droite, 3 pour haut. Une action qui pousse contre un bord laisse l’agent sur place.
Chaque case est un état, numéroté de 0 à 63 en lisant la grille ligne par ligne : la case de la ligne et de la colonne (en partant de 0) porte le numéro . Le départ est l’état 0, l’objectif l’état 63.
La glace peut être glissante ou non. Dans la version glissante, l’agent qui veut aller à droite peut déraper et partir vers le haut ou le bas : le résultat d’une action est aléatoire. En TP, nous avons utilisé la version non glissante (is_slippery=False), où chaque action a une seule issue possible. On dit que l’environnement est déterministe.
Une partie, du départ jusqu’à un trou ou à l’objectif, s’appelle un épisode. Les cases H et G sont des états terminaux : une fois dedans, l’épisode est fini.
Le cadre formel : le processus de décision markovien
Section intitulée « Le cadre formel : le processus de décision markovien »Pour raisonner proprement, on modélise ce genre de problème par un processus de décision markovien (Markov decision process, MDP). C’est un quintuplet :
- est l’ensemble des états (les 64 cases) ;
- est l’ensemble des actions (les 4 directions) ;
- est la probabilité de transition : la probabilité d’arriver en quand on fait l’action depuis l’état ;
- est la récompense reçue lors de cette transition ;
- est le facteur d’actualisation (discount factor), sur lequel nous reviendrons dans un instant.
Dans le lac non glissant, vaut 1 pour une seule case et 0 pour toutes les autres. Dans le lac glissant, la probabilité se répartit sur plusieurs cases voisines.
Pourquoi « markovien » ? Parce que ce modèle suppose la propriété de Markov : la suite ne dépend que de l’état et de l’action présents, pas du chemin suivi pour arriver là. Peu importe que le robot soit passé par le bord ou par le milieu du lac : ce qui compte pour la suite, c’est la case où il se trouve.
Si vous avez suivi le cours de modèles stochastiques, vous reconnaissez les chaînes de Markov. Un MDP, c’est une chaîne de Markov à laquelle on ajoute deux ingrédients : des actions qui modifient les probabilités de transition, et des récompenses. Dès que l’agent fixe sa manière de choisir les actions, on retombe exactement sur une chaîne de Markov.
Dans gymnasium, le modèle du lac est directement accessible. Pour chaque état s et chaque action a, env.unwrapped.P[s][a] est une liste de transitions possibles, chacune sous la forme (probabilité, état suivant, récompense, terminé) :
import gymnasium as gym
# Le lac 8x8 du TP, version non glissante (déterministe)env = gym.make("FrozenLake-v1", map_name="8x8", is_slippery=False)
print(env.observation_space.n) # 64 étatsprint(env.action_space.n) # 4 actions
# Depuis la case 62 (juste à gauche de l'objectif), aller à droite (action 2)print(env.unwrapped.P[62][2]) # [(1.0, 63, 1.0, True)] : on atteint G, récompense 1Une seule transition, de probabilité 1 : c’est la signature d’un environnement déterministe. Avec is_slippery=True, la même commande renverrait trois transitions de probabilité chacune.
Le retour et le facteur d’actualisation
Section intitulée « Le retour et le facteur d’actualisation »L’agent ne cherche pas à maximiser la prochaine récompense, mais tout ce qui va suivre. Cette quantité s’appelle le retour (return) :
- est le retour à partir de l’instant ;
- est la récompense reçue pas plus tard ;
- est le poids de cette récompense : plus elle est lointaine, moins elle compte (si ).
Le facteur joue deux rôles. D’abord, il traduit une préférence pour le présent : un euro aujourd’hui vaut plus qu’un euro dans dix ans. Avec proche de 0, l’agent est myope et ne regarde que la récompense immédiate ; avec proche de 1, il est patient et tient compte d’un avenir lointain. Ensuite, il garantit que le retour reste fini : si toutes les récompenses valent au plus , alors , par la somme d’une série géométrique.
Voyez-vous ce que cela implique ? Dans le lac, toutes les réussites rapportent la même récompense brute, . C’est l’actualisation qui rend les chemins courts plus intéressants que les longs. Sans elle (), un détour de cent pas vaudrait autant que le chemin direct. Nous verrons au chapitre 4 que ce détail a des conséquences très concrètes.
La politique : une règle de décision
Section intitulée « La politique : une règle de décision »Ce que l’agent doit finalement produire, c’est une politique (policy), notée : une règle qui dit quoi faire dans chaque état.
- Une politique stochastique donne une probabilité pour chaque action : est la probabilité de choisir dans l’état , et .
- Une politique déterministe choisit une seule action par état : on écrit alors .
En code, on représente souvent une politique par une matrice de taille (64 lignes, 4 colonnes au lac), dont chaque ligne est une loi de probabilité. La politique aléatoire uniforme, point de départ du TP, met partout. Une politique déterministe met un 1 dans la colonne de l’action choisie et des 0 ailleurs.
import numpy as np
n_etats, n_actions = 64, 4
# Politique aléatoire uniforme : chaque action avec probabilité 1/4politique_aleatoire = np.ones((n_etats, n_actions)) / n_actions
# Politique déterministe « toujours à droite » : un 1 dans la colonne 2politique_droite = np.zeros((n_etats, n_actions))politique_droite[:, 2] = 1.0
print(politique_aleatoire.sum(axis=1)[:3]) # chaque ligne somme à 1La politique « toujours à droite » est mauvaise : depuis la case de départ, elle longe la première ligne et reste bloquée contre le bord droit sans jamais atteindre G. La politique aléatoire n’est guère meilleure : elle finit presque toujours dans un trou. Tout le travail des chapitres suivants consiste à mesurer la qualité d’une politique, puis à l’améliorer jusqu’à la meilleure possible, la politique optimale .
Planifier ou apprendre ?
Section intitulée « Planifier ou apprendre ? »Une dernière distinction va structurer tout le cours. L’agent connaît-il et ?
- Oui : on peut calculer la meilleure politique sans jamais jouer, par un raisonnement sur le modèle. On parle de planification, et les méthodes de programmation dynamique (partie 1 de ce cours, celle du TP) en sont l’exemple type. C’est notre situation avec
env.unwrapped.P. - Non : l’agent doit jouer, observer ce qui se passe et apprendre de ses essais. C’est l’apprentissage par renforcement sans modèle (model-free), objet de la partie 2 : Monte-Carlo, différences temporelles, Q-learning.
| Planification (programmation dynamique) | Apprentissage sans modèle | |
|---|---|---|
| Ce qu’on connaît | et en entier | rien, on interagit |
| Ce qu’on fait | calculer des espérances exactes | estimer à partir d’échantillons |
| Exemple | value iteration sur FrozenLake | Q-learning sur un jeu vidéo |
| Coût | proportionnel à la taille de | proportionnel au nombre d’interactions |
En résumé
Section intitulée « En résumé »- Un agent choisit des actions, l’environnement répond par un nouvel état et une récompense ; l’agent cherche à maximiser les récompenses futures.
- Un MDP formalise ce cadre ; la propriété de Markov dit que la suite ne dépend que de l’état et de l’action présents.
- Le retour est la somme actualisée des récompenses futures ; règle la patience de l’agent et garantit un retour fini.
- Au lac gelé, le plus court chemin (14 pas) rapporte , soit environ 0,254 pour : c’est l’actualisation qui favorise les chemins courts.
- Une politique dit quoi faire dans chaque état ; on la représente par une matrice .
- Si l’on connaît et , on planifie (programmation dynamique) ; sinon, on apprend par interaction (méthodes sans modèle).
Exercices
Section intitulée « Exercices »Exercice · Identifier les éléments d'un MDP
facileUn thermostat intelligent décide toutes les dix minutes d’allumer ou d’éteindre le chauffage. Il connaît la température de la pièce (arrondie au degré, entre 10 et 30 °C). Il est pénalisé de 1 par tranche de dix minutes où la température est hors de la plage 19 à 21 °C, et de 0,1 à chaque fois qu’il chauffe (coût de l’énergie).
Décrivez les états, les actions et la récompense de ce MDP. La propriété de Markov vous semble-t-elle raisonnable ?
Voir la solution
- États : la température arrondie, soit 21 valeurs possibles (de 10 à 30 °C).
- Actions : deux, « chauffer » et « ne pas chauffer ».
- Récompense : si la température est hors de la plage 19 à 21 °C, à quoi s’ajoute si le chauffage est allumé. Les récompenses sont ici des pénalités : maximiser le retour revient à minimiser l’inconfort et la consommation.
La propriété de Markov n’est qu’approximative : l’évolution de la température dépend aussi de la température extérieure, de l’inertie des murs ou d’une fenêtre ouverte, qui ne figurent pas dans l’état. Si ces facteurs comptent beaucoup, il faut les ajouter à l’état (par exemple la température extérieure et la tendance des dernières mesures) pour retrouver un modèle markovien.
En code
import numpy as np
temperatures = np.arange(10, 31) # 21 états : de 10 à 30 °Cactions = ["ne pas chauffer", "chauffer"] # 2 actions
def recompense(temp, chauffe): r = -1.0 if (temp < 19 or temp > 21) else 0.0 # inconfort return r - 0.1 * chauffe # coût de l'énergie
print(len(temperatures), len(actions)) # 21 2for temp in [17, 20, 23]: print(temp, [recompense(temp, c) for c in (0, 1)])# 17 [-1.0, -1.1]# 20 [0.0, -0.1]# 23 [-1.0, -1.1]temperatures <- 10:30 # 21 états : de 10 à 30 °Cactions <- c("ne pas chauffer", "chauffer") # 2 actions
recompense <- function(temp, chauffe) { r <- if (temp < 19 || temp > 21) -1 else 0 # inconfort r - 0.1 * chauffe # coût de l'énergie}
length(temperatures); length(actions) # 21 2for (temp in c(17, 20, 23)) cat(temp, recompense(temp, 0), recompense(temp, 1), "\n")# 17 -1 -1.1# 20 0 -0.1# 23 -1 -1.1Exercice · Calculer des retours
moyenAu lac gelé, on compare deux trajectoires qui atteignent l’objectif : l’une en 14 pas, l’autre en 20 pas.
- Calculez leurs retours depuis le départ pour et pour .
- Un agent reçoit une récompense de 1 à chaque pas, pour toujours. Que vaut son retour avec ? Avec ? Avec ?
Voir la solution
- La récompense arrive au dernier pas, donc le retour vaut pour 14 pas et pour 20 pas. Avec : et ; le chemin court est nettement préféré. Avec : les deux retours valent exactement 1, et rien ne distingue les deux chemins.
- C’est une série géométrique : . Avec , le retour vaut 10 ; avec , il vaut 100. Avec , la somme diverge : le retour est infini, ce qui rend toute comparaison impossible. C’est pourquoi on impose dans les tâches sans fin, alors qu’on peut prendre quand les épisodes se terminent toujours.
En code
import numpy as np
def retour(recompenses, gamma): """G_0 = somme des gamma^k * r_(k+1).""" r = np.asarray(recompenses, dtype=float) return np.sum(gamma ** np.arange(len(r)) * r)
court = [0] * 13 + [1] # 14 pas, +1 au dernierlong_ = [0] * 19 + [1] # 20 pasfor gamma in (0.9, 1.0): print(gamma, round(retour(court, gamma), 3), round(retour(long_, gamma), 3))# 0.9 0.254 0.135# 1.0 1.0 1.0
# Récompense 1 à chaque pas : série géométrique 1 / (1 - gamma)for gamma in (0.9, 0.99): print(gamma, round(1 / (1 - gamma), 3), round(retour([1] * 5000, gamma), 3))# 0.9 10.0 10.0# 0.99 100.0 100.0 (avec gamma = 1, la somme diverge)retour <- function(recompenses, gamma) { sum(gamma^(seq_along(recompenses) - 1) * recompenses)}
court <- c(rep(0, 13), 1) # 14 pas, +1 au dernierlong <- c(rep(0, 19), 1) # 20 pasfor (gamma in c(0.9, 1)) cat(gamma, round(retour(court, gamma), 3), round(retour(long, gamma), 3), "\n")# 0.9 0.254 0.135# 1 1 1
# Récompense 1 à chaque pas : série géométrique 1 / (1 - gamma)for (gamma in c(0.9, 0.99)) cat(gamma, 1 / (1 - gamma), round(retour(rep(1, 5000), gamma), 3), "\n")# 0.9 10 10# 0.99 100 100Exercice · Lire le modèle de gymnasium
moyenDans le lac glissant, une action réussit avec probabilité et fait déraper l’agent sur l’une des deux directions perpendiculaires avec probabilité chacune. L’agent est sur la case 62 (ligne 7, colonne 6) et choisit d’aller à droite.
- Quelles sont les trois transitions possibles (état suivant, récompense, terminé) ?
- Quelle est la récompense moyenne de cette action ?
Voir la solution
- Les directions perpendiculaires à « droite » sont « haut » et « bas ».
- Droite (probabilité ) : l’agent arrive en 63, l’objectif. Récompense 1, épisode terminé.
- Haut (probabilité ) : il arrive en 54 (ligne 6, colonne 6). Sur la carte, c’est un trou H : récompense 0, épisode terminé.
- Bas (probabilité ) : il est sur la dernière ligne et bute contre le bord, il reste en 62. Récompense 0, épisode non terminé.
- La récompense moyenne vaut . Dans la version glissante, même la case voisine de l’objectif est risquée : une fois sur trois, l’agent tombe dans le trou juste au-dessus.
En code
CARTE = ["SFFFFFFF", "FFFFFFFF", "FFFHFFFF", "FFFFFHFF", "FFFHFFFF", "FHHFFFHF", "FHFFHFHF", "FFFHFFFG"]DEPLACEMENTS = {0: (0, -1), 1: (1, 0), 2: (0, 1), 3: (-1, 0)} # gauche, bas, droite, haut
def transitions_glissantes(s, a): """Liste (proba, état suivant, récompense, terminé), comme env.unwrapped.P[s][a].""" i, j = divmod(s, 8) resultat = [] for b in [(a - 1) % 4, a, (a + 1) % 4]: # dérapage ou réussite, 1/3 chacun di, dj = DEPLACEMENTS[b] ni, nj = min(max(i + di, 0), 7), min(max(j + dj, 0), 7) # les bords bloquent case = CARTE[ni][nj] resultat.append((1 / 3, 8 * ni + nj, float(case == "G"), case in "GH")) return resultat
trans = transitions_glissantes(62, 2) # case 62, action droitefor t in trans: print(t)# (0.333, 62, 0.0, False) dérapage vers le bas : bute contre le bord# (0.333, 63, 1.0, True) réussite : l'objectif# (0.333, 54, 0.0, True) dérapage vers le haut : le trouprint(sum(p * r for p, _, r, _ in trans)) # 0.333carte <- c("SFFFFFFF", "FFFFFFFF", "FFFHFFFF", "FFFFFHFF", "FFFHFFFF", "FHHFFFHF", "FHFFHFHF", "FFFHFFFG")di <- c(0, 1, 0, -1); dj <- c(-1, 0, 1, 0) # actions 0 à 3 : gauche, bas, droite, haut
transitions_glissantes <- function(s, a) { i <- s %/% 8; j <- s %% 8 res <- NULL for (b in c((a - 1) %% 4, a, (a + 1) %% 4)) { # 1/3 chacune ni <- min(max(i + di[b + 1], 0), 7) # les bords bloquent nj <- min(max(j + dj[b + 1], 0), 7) case <- substr(carte[ni + 1], nj + 1, nj + 1) res <- rbind(res, data.frame(proba = 1/3, suivant = 8 * ni + nj, r = as.numeric(case == "G"), termine = case %in% c("G", "H"))) } res}
trans <- transitions_glissantes(62, 2) # case 62, action droiteprint(trans)# suivant 62 (r 0, non terminé), 63 (r 1, terminé), 54 (r 0, terminé : le trou)sum(trans$proba * trans$r) # 0.333