Aller au contenu

É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é.

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 :

Vk+1(s)=∑aπ(a∣s)∑s′P(s′∣s,a)[R(s,a,s′)+γ Vk(s′)]V_{k+1}(s) = \sum_{a} \pi(a \mid s) \sum_{s'} P(s' \mid s, a) \Big[ R(s, a, s') + \gamma \, V_k(s') \Big]
  • VkV_k est l’estimation de VπV^\pi après kk 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 VkV_k en Vk+1V_{k+1} est une contraction de rapport γ\gamma : l’écart maximal entre deux fonctions de valeur est multiplié par au plus γ\gamma à chaque application. L’erreur par rapport à VπV^\pi diminue donc au moins comme γk\gamma^k, et VkV_k converge vers l’unique point fixe, qui est VπV^\pi.

En pratique, on s’arrête quand un balayage ne change presque plus rien. On mesure le plus grand changement du balayage :

Δ=max⁡s∣Vk+1(s)−Vk(s)∣\Delta = \max_{s} \big\lvert V_{k+1}(s) - V_k(s) \big\rvert

et on arrête dès que Δ<θ\Delta < \theta, un petit seuil fixé à l’avance (θ=10−5\theta = 10^{-5} en TP).

Il existe deux façons de faire un balayage. La version synchrone calcule toutes les nouvelles valeurs à partir de l’ancien tableau VkV_k, 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 VπV^\pi, et en général plus vite.

0246810121416balayage k0,00,10,20,30,40,50,60,70,80,9estimation de VV(A) ≈ 0,647V(B) ≈ 0,791en place : 0,601dès le 2e balayageen placesynchrone
Les deux versions convergent vers les mêmes valeurs. La version en place (traits pleins) profite dès le 2e balayage de la nouvelle valeur de A et garde un balayage d'avance.

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, balayages

Sur le lac 8x8 non glissant, en TP, l’évaluation de la politique aléatoire uniforme avec γ=0,9\gamma = 0{,}9 a convergé en 26 balayages. Nous avons ensuite fait varier γ\gamma :

γ\gamma0,80,90,951,0
Balayages jusqu’à Δ<10−5\Delta < 10^{-5}17263771

Plus γ\gamma est grand, plus l’évaluation est lente. C’est la contraction qui l’explique : avec γ=0,8\gamma = 0{,}8, chaque balayage réduit fortement l’erreur ; avec γ\gamma proche de 1, beaucoup moins, et l’information venue de l’objectif doit se propager plus loin avant de devenir négligeable. Avec γ=1\gamma = 1, 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 γ=0,8\gamma = 0{,}8.

010203040506070balayage10−610−510−410−310−210−1100plus grand changement Δ17263771seuil θ = 10⁻⁵γ = 0,8γ = 0,9γ = 0,95γ = 1
Le plus grand changement Δ décroît à peu près géométriquement ; plus γ est proche de 1, plus la pente est douce et plus il faut de balayages pour passer sous θ.

Supposons que nous connaissions VπV^\pi. Dans un état ss, demandons-nous : que se passerait-il si je faisais l’action aa une fois, puis que je reprenais ma politique habituelle ? La réponse est justement Qπ(s,a)Q^\pi(s, a), qu’on calcule à partir de VπV^\pi en regardant un pas en avant :

Qπ(s,a)=∑s′P(s′∣s,a)[R(s,a,s′)+γ Vπ(s′)]Q^\pi(s, a) = \sum_{s'} P(s' \mid s, a) \Big[ R(s, a, s') + \gamma \, V^\pi(s') \Big]

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 :

π′(s)=arg⁡max⁡aQπ(s,a)\pi'(s) = \arg\max_{a} Q^\pi(s, a)

Le théorème d’amélioration de politique garantit que ce changement ne peut pas nuire : Vπ′(s)≥Vπ(s)V^{\pi'}(s) \geq V^\pi(s) pour tous les états. Et si l’on n’a rien gagné nulle part, c’est que π\pi vérifiait déjà l’équation de Bellman d’optimalité : elle était optimale.

Pourquoi ? L’intuition tient en une phrase. Dans chaque état, faire π′(s)\pi'(s) une fois puis suivre π\pi rapporte max⁡aQπ(s,a)≥Vπ(s)\max_a Q^\pi(s, a) \geq V^\pi(s) ; en répétant l’argument à chaque pas, suivre π′\pi' 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 politique
0,00,51,00,647V avant0,583Q gauche0,712Q droite0,900V aprèsétat A0,791V avant0,583Q gauche1,000Q droite1,000V aprèsétat Bavant : politique aléatoire ; après : politique gloutonne, « droite » partout
Dans chaque état, l'action gloutonne est celle dont la barre Q est la plus haute (droite). La nouvelle valeur dépasse l'ancienne dans les deux états, comme le garantit le théorème.

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

  1. Partir d’une politique π0\pi_0 quelconque (la politique aléatoire uniforme en TP).
  2. Évaluer : calculer VπkV^{\pi_k} par évaluation itérative.
  3. Améliorer : πk+1←\pi_{k+1} \leftarrow la politique gloutonne par rapport à VπkV^{\pi_k}.
  4. Si πk+1=πk\pi_{k+1} = \pi_k, s’arrêter : la politique est optimale. Sinon, revenir à l’étape 2.
π0→eˊval.Vπ0→ameˊlio.π1→eˊval.Vπ1→ameˊlio.π2⟶⋯⟶π∗\pi_0 \xrightarrow{\text{éval.}} V^{\pi_0} \xrightarrow{\text{amélio.}} \pi_1 \xrightarrow{\text{éval.}} V^{\pi_1} \xrightarrow{\text{amélio.}} \pi_2 \longrightarrow \dots \longrightarrow \pi^*

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 (∣A∣∣S∣\lvert A \rvert^{\lvert S \rvert}). 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, V

Sur le lac, en TP, avec γ=0,9\gamma = 0{,}9, 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.

01020304050607080balayages cumulés (26 + 15 + 15 + 15 = 71)10−710−610−510−410−310−210−1100101Δ du balayageéval. π₀26 balayageséval. π₁15 balayageséval. π₂15 balayageséval. π₃15 balayagesπ₄ = π₃ :stable- - - amélioration de la politiqueθΔ = 0au 15e
Policy iteration vue balayage par balayage : une longue évaluation de la politique aléatoire, puis trois évaluations de politiques déterministes, où Δ reste grand puis tombe d'un coup à 0 au 15e balayage.

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 (Δ=0\Delta = 0).

1413121110987131211109876121110H87651110987H5411109H654311HH654H210H76H3H1987H321Gnuméro du balayage où la valeur de la case devient définitive
Évaluation de la politique optimale, en place : la valeur définitive remonte d'environ une case par balayage depuis G et atteint le départ au 14e.
  • L’évaluation de politique itère l’équation de Bellman d’espérance jusqu’à Δ<θ\Delta < \theta ; elle converge car la mise à jour est une contraction de rapport γ\gamma.
  • 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 γ\gamma = 0,8, 0,9, 0,95 et 1 : plus γ\gamma est grand, plus c’est lent.
  • L’amélioration de politique rend la politique gloutonne par rapport à QπQ^\pi ; le théorème d’amélioration garantit Vπ′≥VπV^{\pi'} \geq V^\pi 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 (γ=0,9\gamma = 0{,}9) : 4 itérations, avec des évaluations de 26, 15, 15 et 15 balayages.

Exercice · Dérouler une évaluation à la main

facile

Dans le couloir A, B, G (γ=0,9\gamma = 0{,}9), on évalue la politique « toujours à droite » par balayages synchrones, en partant de V=(0,0)V = (0, 0).

  1. Calculez V1V_1, V2V_2 et V3V_3.
  2. Au bout de combien de balayages Δ\Delta devient-il nul ?
Voir la solution

Les équations sont Vk+1(A)=0,9 Vk(B)V_{k+1}(A) = 0{,}9 \, V_k(B) et Vk+1(B)=1V_{k+1}(B) = 1 (aller à droite depuis B atteint G).

  1. V1=(0,9×0,  1)=(0,1)V_1 = (0{,}9 \times 0, \; 1) = (0, 1) ; V2=(0,9×1,  1)=(0,9,1)V_2 = (0{,}9 \times 1, \; 1) = (0{,}9, 1) ; V3=(0,9,1)V_3 = (0{,}9, 1).
  2. Le premier balayage change V(B)V(B) de 1, le deuxième change V(A)V(A) de 0,9, le troisième ne change rien : Δ=0\Delta = 0 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.9
V = 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 balayage

Exercice · Améliorer une politique médiocre

moyen

Dans le couloir, on part de la politique « en A aller à gauche, en B aller à gauche ».

  1. Que vaut VπV^\pi ?
  2. Calculez les valeurs QπQ^\pi des quatre couples (état, action) et la politique gloutonne. Pourquoi est-ce ennuyeux en A ?
  3. Déroulez policy iteration jusqu’au bout. Combien d’itérations faut-il ?
Voir la solution
  1. Le robot ne va jamais en G : Vπ(A)=Vπ(B)=0V^\pi(A) = V^\pi(B) = 0.
  2. Qπ(A,gauche)=0,9×0=0Q^\pi(A, \text{gauche}) = 0{,}9 \times 0 = 0, Qπ(A,droite)=0,9×Vπ(B)=0Q^\pi(A, \text{droite}) = 0{,}9 \times V^\pi(B) = 0, Qπ(B,gauche)=0Q^\pi(B, \text{gauche}) = 0, Qπ(B,droite)=1Q^\pi(B, \text{droite}) = 1. 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.
  3. Itération 1 : on évalue (V=(0,0)V = (0, 0)), on améliore, on obtient (gauche, droite), différente de la politique de départ. Itération 2 : on évalue (gauche, droite), qui donne V(A)=0V(A) = 0 (le robot reste bloqué contre le mur) et V(B)=1V(B) = 1 ; on améliore : en A, droite vaut 0,90{,}9 contre 00 pour gauche, on obtient (droite, droite). Itération 3 : on évalue, V=(0,9,1)V = (0{,}9, 1), 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 = droite
suivant = 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 partout
for 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érations

Exercice · Pourquoi γ ralentit l'évaluation

difficile

On admet que chaque balayage synchrone multiplie l’erreur maximale ∥Vk−Vπ∥∞\lVert V_k - V^\pi \rVert_\infty par au plus γ\gamma, et que l’erreur initiale (avec V0=0V_0 = 0) vaut au plus 1/(1−γ)1/(1-\gamma) quand les récompenses sont comprises entre 0 et 1.

  1. Majorez le nombre de balayages nécessaires pour garantir une erreur inférieure à 10−510^{-5}, pour γ=0,8\gamma = 0{,}8 puis γ=0,9\gamma = 0{,}9.
  2. En TP, il a fallu 17 et 26 balayages. Pourquoi ces nombres sont-ils plus petits que vos bornes ?
Voir la solution
  1. Après kk balayages, l’erreur est au plus γk/(1−γ)\gamma^k / (1 - \gamma). On cherche le plus petit kk tel que γk/(1−γ)<10−5\gamma^k / (1 - \gamma) < 10^{-5}, soit k>ln⁡(10−5(1−γ))/ln⁡γk > \ln\big(10^{-5}(1-\gamma)\big) / \ln \gamma.
    • γ=0,8\gamma = 0{,}8 : ln⁡(2×10−6)/ln⁡0,8≈−13,12/−0,223≈58,8\ln(2 \times 10^{-6}) / \ln 0{,}8 \approx -13{,}12 / -0{,}223 \approx 58{,}8, donc 59 balayages.
    • γ=0,9\gamma = 0{,}9 : ln⁡(10−6)/ln⁡0,9≈−13,82/−0,105≈131,1\ln(10^{-6}) / \ln 0{,}9 \approx -13{,}82 / -0{,}105 \approx 131{,}1, donc 132 balayages.
  2. 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 Δ\Delta 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 γ\gamma, et explose quand γ\gamma s’approche de 1.

En code

import numpy as np
# Plus petit k tel que gamma^k / (1 - gamma) < 1e-5
for 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)