Évaluez puis améliorez une politique
Vous savez maintenant ce que vaut une politique : sa fonction de valeur, solution de l’équation de Bellman. Deux questions pratiques restent ouvertes. Comment calculer cette valeur quand le système est trop grand pour être inversé ? Et, une fois la valeur connue, comment s’en servir pour faire mieux ?
Ce chapitre répond aux deux, avec les deux premières briques de la programmation dynamique : l’évaluation de politique (policy evaluation) et l’amélioration de politique (policy improvement). En les alternant, on obtient un premier algorithme qui trouve la politique optimale : l’itération sur les politiques (policy iteration). C’est exactement ce que nous avons programmé en TP sur le lac gelé.
Évaluer une politique par itérations
Section intitulée « Évaluer une politique par itérations »L’idée est de transformer l’équation de Bellman en règle de mise à jour. On part de valeurs arbitraires (des zéros), puis on applique l’équation à chaque état, encore et encore :
- est l’estimation de après passages ;
- un passage sur tous les états s’appelle un balayage (sweep).
Pourquoi est-ce que ça converge ? Parce que l’opération qui transforme en est une contraction de rapport : l’écart maximal entre deux fonctions de valeur est multiplié par au plus à chaque application. L’erreur par rapport à diminue donc au moins comme , et converge vers l’unique point fixe, qui est .
En pratique, on s’arrête quand un balayage ne change presque plus rien. On mesure le plus grand changement du balayage :
et on arrête dès que , un petit seuil fixé à l’avance ( en TP).
Synchrone ou en place ?
Section intitulée « Synchrone ou en place ? »Il existe deux façons de faire un balayage. La version synchrone calcule toutes les nouvelles valeurs à partir de l’ancien tableau , puis remplace tout d’un coup (il faut deux tableaux). La version en place (in-place) écrase chaque valeur dès qu’elle est calculée : les états traités plus tard dans le balayage profitent déjà des valeurs à jour. C’est le principe de la méthode de Gauss-Seidel en analyse numérique. Elle converge aussi vers , et en général plus vite.
Voici la fonction écrite en TP, qui travaille en place. Elle lit la dynamique du lac dans env.unwrapped.P, présentée au chapitre 1.
import numpy as np
def evaluation_politique(env, politique, gamma=0.9, theta=1e-5): """Calcule V^pi par balayages successifs de l'équation de Bellman.""" n_etats = env.observation_space.n V = np.zeros(n_etats) balayages = 0 while True: delta = 0.0 for s in range(n_etats): v = 0.0 for a, proba_a in enumerate(politique[s]): for p, s_suiv, r, termine in env.unwrapped.P[s][a]: v += proba_a * p * (r + gamma * V[s_suiv]) delta = max(delta, abs(V[s] - v)) V[s] = v # mise à jour en place balayages += 1 if delta < theta: return V, balayagesSur le lac 8x8 non glissant, en TP, l’évaluation de la politique aléatoire uniforme avec a convergé en 26 balayages. Nous avons ensuite fait varier :
| 0,8 | 0,9 | 0,95 | 1,0 | |
|---|---|---|---|---|
| Balayages jusqu’à | 17 | 26 | 37 | 71 |
Plus est grand, plus l’évaluation est lente. C’est la contraction qui l’explique : avec , chaque balayage réduit fortement l’erreur ; avec proche de 1, beaucoup moins, et l’information venue de l’objectif doit se propager plus loin avant de devenir négligeable. Avec , il n’y a même plus de contraction garantie. L’évaluation converge quand même ici, parce que tous les épisodes finissent dans un trou ou sur l’objectif, mais elle est plus de quatre fois plus lente qu’avec .
Améliorer une politique : devenir glouton
Section intitulée « Améliorer une politique : devenir glouton »Supposons que nous connaissions . Dans un état , demandons-nous : que se passerait-il si je faisais l’action une fois, puis que je reprenais ma politique habituelle ? La réponse est justement , qu’on calcule à partir de en regardant un pas en avant :
Si une action fait mieux que la politique actuelle, autant la prendre. La politique gloutonne (greedy) choisit, dans chaque état, l’action de plus grande valeur :
Le théorème d’amélioration de politique garantit que ce changement ne peut pas nuire : pour tous les états. Et si l’on n’a rien gagné nulle part, c’est que vérifiait déjà l’équation de Bellman d’optimalité : elle était optimale.
Pourquoi ? L’intuition tient en une phrase. Dans chaque état, faire une fois puis suivre rapporte ; en répétant l’argument à chaque pas, suivre tout le temps rapporte au moins autant.
def amelioration_politique(env, V, gamma=0.9): """Renvoie la politique déterministe gloutonne par rapport à V.""" n_etats, n_actions = env.observation_space.n, env.action_space.n politique = np.zeros((n_etats, n_actions)) for s in range(n_etats): q = np.zeros(n_actions) for a in range(n_actions): for p, s_suiv, r, termine in env.unwrapped.P[s][a]: q[a] += p * (r + gamma * V[s_suiv]) politique[s, np.argmax(q)] = 1.0 # un 1 sur la meilleure action return politiquePolicy iteration : alterner jusqu’à la stabilité
Section intitulée « Policy iteration : alterner jusqu’à la stabilité »On a maintenant deux opérations complémentaires : l’évaluation mesure une politique, l’amélioration en produit une meilleure. L’itération sur les politiques (policy iteration) les enchaîne :
- Partir d’une politique quelconque (la politique aléatoire uniforme en TP).
- Évaluer : calculer par évaluation itérative.
- Améliorer : la politique gloutonne par rapport à .
- Si , s’arrêter : la politique est optimale. Sinon, revenir à l’étape 2.
Pourquoi est-on sûr que ça s’arrête ? Dans un MDP fini, il n’y a qu’un nombre fini de politiques déterministes (). Chaque itération produit une politique strictement meilleure, sauf si elle est déjà optimale. On ne peut donc pas tourner en rond, et l’algorithme termine. En pratique, il lui faut remarquablement peu d’itérations.
def policy_iteration(env, gamma=0.9, max_iterations=200): n_etats, n_actions = env.observation_space.n, env.action_space.n politique = np.ones((n_etats, n_actions)) / n_actions # départ aléatoire for i in range(max_iterations): V, balayages = evaluation_politique(env, politique, gamma) nouvelle = amelioration_politique(env, V, gamma) if np.array_equal(nouvelle, politique): print(f"Politique stable après {i + 1} itérations") return politique, V politique = nouvelle print("Nombre maximal d'itérations atteint") return politique, VSur le lac, en TP, avec , la politique optimale a été trouvée en 4 itérations. Les quatre évaluations successives ont demandé 26, 15, 15 et 15 balayages. La première évaluation, celle de la politique aléatoire, est la plus longue ; les suivantes portent sur des politiques déterministes, et se stabilisent en 15 balayages.
Pourquoi 15 ? Une explication tient à la forme du lac. Avec une politique déterministe dans un environnement déterministe, la valeur d’une case est fixée dès que celle de sa case suivante l’est. Or la politique mène surtout vers le bas et la droite, c’est-à-dire vers des cases que le balayage (dans l’ordre 0, 1, …, 63) traite après : l’information venue de l’objectif ne remonte donc que d’une case par balayage. Le chemin le plus long vers G fait 14 pas ; au 14e balayage, toutes les valeurs sont en place, et le 15e constate qu’il n’y a plus rien à changer ().
En résumé
Section intitulée « En résumé »- L’évaluation de politique itère l’équation de Bellman d’espérance jusqu’à ; elle converge car la mise à jour est une contraction de rapport .
- La version en place (façon Gauss-Seidel) réutilise immédiatement les nouvelles valeurs et converge en général plus vite que la version synchrone.
- En TP, l’évaluation de la politique aléatoire a demandé 17, 26, 37 et 71 balayages pour = 0,8, 0,9, 0,95 et 1 : plus est grand, plus c’est lent.
- L’amélioration de politique rend la politique gloutonne par rapport à ; le théorème d’amélioration garantit partout.
- Policy iteration alterne évaluation complète et amélioration jusqu’à ce que la politique ne change plus ; elle termine en un nombre fini d’itérations.
- En TP () : 4 itérations, avec des évaluations de 26, 15, 15 et 15 balayages.
Exercices
Section intitulée « Exercices »Exercice · Dérouler une évaluation à la main
facileDans le couloir A, B, G (), on évalue la politique « toujours à droite » par balayages synchrones, en partant de .
- Calculez , et .
- Au bout de combien de balayages devient-il nul ?
Voir la solution
Les équations sont et (aller à droite depuis B atteint G).
- ; ; .
- Le premier balayage change de 1, le deuxième change de 0,9, le troisième ne change rien : au 3e balayage. C’est le même mécanisme qu’au lac : la valeur remonte d’une case par balayage depuis l’objectif, et un balayage de plus constate la stabilité. (En place, dans l’ordre A puis B, ce serait identique : A est traité avant que B ne soit mis à jour.)
En code
import numpy as np
gamma = 0.9V = np.zeros(2) # V(A), V(B)for k in range(1, 4): nouveau = np.array([0 + gamma * V[1], # A --droite--> B, récompense 0 1 + gamma * 0]) # B --droite--> G, récompense 1 delta = np.abs(nouveau - V).max() V = nouveau # balayage synchrone print(k, V, round(delta, 3))# 1 [0. 1.] 1.0# 2 [0.9 1. ] 0.9# 3 [0.9 1. ] 0.0 -> Delta nul au 3e balayagegamma <- 0.9V <- c(A = 0, B = 0)for (k in 1:3) { nouveau <- c(A = 0 + gamma * V[["B"]], # A --droite--> B, récompense 0 B = 1 + gamma * 0) # B --droite--> G, récompense 1 delta <- max(abs(nouveau - V)) V <- nouveau # balayage synchrone cat(k, V, delta, "\n")}# 1 0 1 1# 2 0.9 1 0.9# 3 0.9 1 0 -> Delta nul au 3e balayageExercice · Améliorer une politique médiocre
moyenDans le couloir, on part de la politique « en A aller à gauche, en B aller à gauche ».
- Que vaut ?
- Calculez les valeurs des quatre couples (état, action) et la politique gloutonne. Pourquoi est-ce ennuyeux en A ?
- Déroulez policy iteration jusqu’au bout. Combien d’itérations faut-il ?
Voir la solution
- Le robot ne va jamais en G : .
- , , , . En B, la politique gloutonne passe à « droite ». En A, les deux actions sont à égalité (0 et 0) : l’argmax choisit la première, « gauche ». La politique ne progresse pas en A à cette itération, car la valeur de B n’a pas encore profité du changement.
- Itération 1 : on évalue (), on améliore, on obtient (gauche, droite), différente de la politique de départ. Itération 2 : on évalue (gauche, droite), qui donne (le robot reste bloqué contre le mur) et ; on améliore : en A, droite vaut contre pour gauche, on obtient (droite, droite). Itération 3 : on évalue, , l’amélioration redonne (droite, droite) : la politique est stable. Il faut 3 itérations, dont la dernière sert seulement à constater la stabilité.
En code
import numpy as np
gamma = 0.9# Couloir : états 0 = A, 1 = B, 2 = G (terminal) ; actions 0 = gauche, 1 = droitesuivant = np.array([[0, 1], [0, 2]])recompense = np.array([[0.0, 0.0], [0.0, 1.0]])
def evaluer(pol): """Résout V = R + gamma P V pour une politique déterministe.""" P, R = np.zeros((2, 2)), np.zeros(2) for s in range(2): s2 = suivant[s, pol[s]] R[s] = recompense[s, pol[s]] if s2 < 2: P[s, s2] = 1.0 return np.linalg.solve(np.eye(2) - gamma * P, R)
pol = np.array([0, 0]) # gauche partoutfor it in range(1, 10): V = np.append(evaluer(pol), 0.0) # V(G) = 0 Q = recompense + gamma * V[suivant] # Q[s, a], un pas en avant nouvelle = Q.argmax(axis=1) # en cas d'égalité : la première action print(it, V[:2].round(3) + 0.0, Q.round(3).tolist(), nouvelle) if np.array_equal(nouvelle, pol): break pol = nouvelle# 1 [0. 0.] [[0.0, 0.0], [0.0, 1.0]] [0 1]# 2 [0. 1.] [[0.0, 0.9], [0.0, 1.0]] [1 1]# 3 [0.9 1. ] [[0.81, 0.9], [0.81, 1.0]] [1 1] -> stable après 3 itérationsgamma <- 0.9# Couloir : états 1 = A, 2 = B, 3 = G (terminal) ; actions 1 = gauche, 2 = droitesuivant <- matrix(c(1, 2, 1, 3), nrow = 2, byrow = TRUE)recompense <- matrix(c(0, 0, 0, 1), nrow = 2, byrow = TRUE)
evaluer <- function(pol) { # résout V = R + gamma P V P <- matrix(0, 2, 2); R <- numeric(2) for (s in 1:2) { s2 <- suivant[s, pol[s]]; R[s] <- recompense[s, pol[s]] if (s2 < 3) P[s, s2] <- 1 } solve(diag(2) - gamma * P, R)}
pol <- c(1, 1) # gauche partoutfor (it in 1:10) { V <- c(evaluer(pol), 0) # V(G) = 0 Q <- recompense + gamma * matrix(V[suivant], 2) # Q[s, a], un pas en avant nouvelle <- apply(Q, 1, which.max) # égalité : la première action cat(it, round(V[1:2], 3), "| Q :", round(t(Q), 3), "|", nouvelle, "\n") if (all(nouvelle == pol)) break pol <- nouvelle}# 1 0 0 | Q : 0 0 0 1 | 1 2# 2 0 1 | Q : 0 0.9 0 1 | 2 2# 3 0.9 1 | Q : 0.81 0.9 0.81 1 | 2 2 -> stable après 3 itérationsExercice · Pourquoi γ ralentit l'évaluation
difficileOn admet que chaque balayage synchrone multiplie l’erreur maximale par au plus , et que l’erreur initiale (avec ) vaut au plus quand les récompenses sont comprises entre 0 et 1.
- Majorez le nombre de balayages nécessaires pour garantir une erreur inférieure à , pour puis .
- En TP, il a fallu 17 et 26 balayages. Pourquoi ces nombres sont-ils plus petits que vos bornes ?
Voir la solution
- Après balayages, l’erreur est au plus . On cherche le plus petit tel que , soit .
- : , donc 59 balayages.
- : , donc 132 balayages.
- Ce sont des bornes dans le pire des cas. Au lac, elles sont très pessimistes : les récompenses sont presque toutes nulles, les trous arrêtent les épisodes (ce qui accélère la convergence), la mise à jour en place va plus vite que la version synchrone, et le critère du TP porte sur le changement entre deux balayages, pas sur l’erreur réelle. La borne garde toutefois le bon sens de variation : le nombre de balayages croît avec , et explose quand s’approche de 1.
En code
import numpy as np
# Plus petit k tel que gamma^k / (1 - gamma) < 1e-5for gamma in (0.8, 0.9): borne = np.log(1e-5 * (1 - gamma)) / np.log(gamma) print(gamma, round(borne, 1), int(np.floor(borne)) + 1)# 0.8 58.8 59# 0.9 131.1 132 (contre 17 et 26 balayages observés en TP)# Plus petit k tel que gamma^k / (1 - gamma) < 1e-5for (gamma in c(0.8, 0.9)) { borne <- log(1e-5 * (1 - gamma)) / log(gamma) cat(gamma, round(borne, 1), floor(borne) + 1, "\n")}# 0.8 58.8 59# 0.9 131.1 132 (contre 17 et 26 balayages observés en TP)