Vai al contenuto
Apprendimento per Rinforzo

Gradiente della policy e PPO: REINFORCE, baseline, actor-critic, GAE e obiettivo ritagliato

Il teorema del gradiente della policy dimostrato, REINFORCE, la baseline che non introduce distorsione, actor-critic, il vantaggio generalizzato GAE, l'obiettivo ritagliato del PPO, codice su CartPole con ambienti vettorizzati.

Il DQN impara i valori e ne ricava la policy. Si può anche fare il contrario: parametrizzare direttamente la policy \pi_\theta(a \mid s) con una rete neurale e spostarne i pesi nella direzione che aumenta il ritorno medio. Sono i metodi a gradiente della policy. Funzionano con azioni continue (coppie, sterzo, accelerazione), imparano policy stocastiche e sono la base del PPO (Proximal Policy Optimization, Schulman e altri, 2017), l’algoritmo più usato oggi: dai robot che camminano ai modelli linguistici addestrati con il feedback umano. Questa lezione dimostra il teorema del gradiente della policy, ricava REINFORCE, la baseline e l’actor-critic, spiega il vantaggio generalizzato (GAE) e l’obiettivo “ritagliato” del PPO, e lo applica a CartPole.

Il teorema del gradiente della policy

L’obiettivo è il ritorno medio J(\theta) = \mathbb E_{\tau\sim\pi_\theta}[G(\tau)], dove \tau = (s_0, a_0, r_1, s_1, a_1, \dots) è una traiettoria e G(\tau) il suo ritorno.

Teorema. \displaystyle\nabla_\theta J(\theta) = \mathbb E_{\tau\sim\pi_\theta}\Big[\sum_t\nabla_\theta\log\pi_\theta(a_t \mid s_t)\,G_t\Big], con G_t il ritorno da t in poi.

Dimostrazione. La probabilità di una traiettoria è p_\theta(\tau) = p(s_0)\prod_t\pi_\theta(a_t \mid s_t)P(s_{t+1} \mid s_t, a_t). Si usa il trucco della derivata logaritmica, \nabla p = p\,\nabla\log p:

    \[ \nabla_\theta J = \nabla_\theta\int p_\theta(\tau)G(\tau)\,d\tau = \int p_\theta(\tau)\,\nabla_\theta\log p_\theta(\tau)\,G(\tau)\,d\tau = \mathbb E\big[\nabla_\theta\log p_\theta(\tau)\,G(\tau)\big] \]

Nel logaritmo il prodotto diventa una somma, e i termini p(s_0) e P(s_{t+1} \mid s_t, a_t) non dipendono da \theta: spariscono derivando. Resta \nabla\log p_\theta(\tau) = \sum_t\nabla\log\pi_\theta(a_t \mid s_t). Infine le ricompense ricevute prima di t non dipendono da a_t, e il loro contributo ha media nulla (per lo stesso argomento della baseline qui sotto): si può sostituire G(\tau) con G_t. ∎

Il risultato è notevole: per migliorare la policy non serve il modello dell’ambiente, basta poter derivare la propria policy. L’interpretazione è intuitiva: aumenta la probabilità delle azioni seguite da un ritorno alto, diminuisci quella delle azioni seguite da un ritorno basso. Stimando la media con le traiettorie giocate si ottiene l’algoritmo REINFORCE (Williams, 1992).

La baseline e l’actor-critic

REINFORCE è corretto ma lentissimo: G_t ha una varianza enorme. Si può sottrarre una baseline b(s_t) senza cambiare la media.

Lemma. \mathbb E_{a\sim\pi_\theta}[\nabla_\theta\log\pi_\theta(a \mid s)\,b(s)] = 0.

Dimostrazione. \sum_a\pi_\theta(a \mid s)\nabla\log\pi_\theta(a \mid s)\,b(s) = b(s)\sum_a\nabla\pi_\theta(a \mid s) = b(s)\nabla\sum_a\pi_\theta(a \mid s) = b(s)\nabla 1 = 0. ∎

La scelta naturale è b(s) = V(s): al posto di G_t si usa il vantaggio A_t = G_t - V(s_t), che dice di quanto l’azione è andata meglio del previsto. V si impara con una seconda rete, il critico, mentre la policy è l’attore: è lo schema actor-critic, la versione “valuta e migliora” dell’iterazione della policy.

Il vantaggio generalizzato (GAE). Come tra Monte Carlo e TD, si può stimare il vantaggio con l’errore TD \delta_t = r_{t+1} + \gamma V(s_{t+1}) - V(s_t) (poca varianza, distorto) o con il ritorno intero (non distorto, molta varianza). Il GAE (Schulman, 2016) ne fa una media esponenziale:

    \[ \hat A_t = \sum_{l=0}^{\infty}(\gamma\lambda)^l\,\delta_{t+l} = \delta_t + \gamma\lambda\,\hat A_{t+1} \]

con \lambda = 0 si ha l’errore TD, con \lambda = 1 il ritorno Monte Carlo meno V; di solito \lambda = 0{,}95. La forma ricorsiva si calcola all’indietro in una riga di codice.

Il PPO: passi grandi ma non troppo

Un passo di gradiente troppo lungo può cambiare la policy così tanto da rovinarla, e i dati raccolti con la vecchia policy non valgono più. Il PPO usa il rapporto delle probabilità \rho_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_{old}}(a_t \mid s_t)} tra la policy nuova e quella che ha raccolto i dati, e massimizza l’obiettivo ritagliato:

    \[ L^{CLIP}(\theta) = \mathbb E_t\Big[\min\big(\rho_t\hat A_t,\ \text{clip}(\rho_t, 1 - \epsilon, 1 + \epsilon)\,\hat A_t\big)\Big] \]

con \epsilon = 0{,}2. Se un’azione era buona (\hat A \gt  0) se ne aumenta la probabilità, ma oltre il 20 % in più l’obiettivo diventa piatto e il gradiente si annulla; se era cattiva (\hat A \lt  0) se ne riduce la probabilità, ma non sotto il 20 % in meno. Il minimo garantisce che il ritaglio agisca solo quando migliorerebbe l’obiettivo, mai quando lo peggiora: è un limite pessimistico al guadagno. Così si possono fare più passi (epoche) sugli stessi dati in sicurezza. All’obiettivo si aggiungono l’errore del critico e un piccolo premio all’entropia, che mantiene l’esplorazione.

L'obiettivo ritagliato del PPO in funzione del rapporto delle probabilità
Per un vantaggio positivo, curva blu, l’obiettivo cresce con il rapporto fino a 1 più epsilon e poi resta piatto: aumentare ancora la probabilità non conviene. Per un vantaggio negativo, curva rossa, l’obiettivo è piatto per rapporti sotto 1 meno epsilon e scende per rapporti più grandi: conviene ridurre la probabilità, ma solo fino a 1 meno epsilon. Le linee tratteggiate segnano 1 meno epsilon e 1 più epsilon.

Il PPO su CartPole

Il codice raccoglie 128 passi da 8 ambienti in parallelo (1024 transizioni), calcola il GAE, fa 4 epoche di aggiornamento con l’obiettivo ritagliato e ripete 100 volte. Gli ambienti sono vettorizzati con PufferLib, presentato nella lezione sulle librerie; con Gymnasium (gymnasium.vector.SyncVectorEnv) il codice sarebbe quasi identico.

import numpy as np
import torch, torch.nn as nn
import gymnasium
import pufferlib.emulation, pufferlib.vector

N_ENV, T, AGGIORNAMENTI = 8, 128, 100            # 8 ambienti, 128 passi ciascuno per aggiornamento
GAMMA, LAM, CLIP, LR, EPOCHE = 0.99, 0.95, 0.2, 1e-3, 4
torch.manual_seed(0)

def crea(buf=None, seed=0):
    return pufferlib.emulation.GymnasiumPufferEnv(env=gymnasium.make("CartPole-v1"), buf=buf, seed=seed)
envs = pufferlib.vector.make(crea, num_envs=N_ENV, backend=pufferlib.vector.Serial)
obs, _ = envs.reset(seed=0)

attore = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 2))
critico = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 1))
parametri = list(attore.parameters()) + list(critico.parameters())
opt = torch.optim.Adam(parametri, lr=LR)

for k in range(AGGIORNAMENTI):
    for g in opt.param_groups:
        g["lr"] = LR * (1 - k / AGGIORNAMENTI)          # passo che scende linearmente a zero
    O, A, LP, R, D, V = [], [], [], [], [], []
    for t in range(T):                                  # raccolta dei dati con la policy attuale
        o = torch.tensor(obs, dtype=torch.float32)      # copia: PufferLib riusa lo stesso buffer
        with torch.no_grad():
            dist = torch.distributions.Categorical(logits=attore(o))
            a = dist.sample()
            O.append(o); A.append(a); LP.append(dist.log_prob(a)); V.append(critico(o).squeeze(-1))
        obs, r, term, trunc, _ = envs.step(a.numpy())
        R.append(torch.tensor(r, dtype=torch.float32))
        D.append(torch.tensor(np.logical_or(term, trunc), dtype=torch.float32))
    with torch.no_grad():
        v_fine = critico(torch.tensor(obs, dtype=torch.float32)).squeeze(-1)
    vant, ultimo = torch.zeros(T, N_ENV), torch.zeros(N_ENV)
    for t in reversed(range(T)):                        # GAE, calcolato all'indietro
        v_dopo = v_fine if t == T - 1 else V[t + 1]
        delta = R[t] + GAMMA * v_dopo * (1 - D[t]) - V[t]
        ultimo = delta + GAMMA * LAM * (1 - D[t]) * ultimo
        vant[t] = ultimo
    ritorni = (vant + torch.stack(V)).reshape(-1)
    O, A, LP = torch.stack(O).reshape(-1, 4), torch.stack(A).reshape(-1), torch.stack(LP).reshape(-1)
    vant = vant.reshape(-1)
    vant = (vant - vant.mean()) / (vant.std() + 1e-8)
    for _ in range(EPOCHE):                             # obiettivo ritagliato del PPO
        dist = torch.distributions.Categorical(logits=attore(O))
        rapporto = torch.exp(dist.log_prob(A) - LP)
        perdita_pi = -torch.min(rapporto * vant, torch.clamp(rapporto, 1 - CLIP, 1 + CLIP) * vant).mean()
        perdita_v = ((critico(O).squeeze(-1) - ritorni) ** 2).mean()
        perdita = perdita_pi + 0.5 * perdita_v - 0.01 * dist.entropy().mean()
        opt.zero_grad(); perdita.backward()
        nn.utils.clip_grad_norm_(parametri, 0.5)
        opt.step()
envs.close()

env, punti = gymnasium.make("CartPole-v1"), []
for e in range(10):                                     # valutazione con l'azione più probabile
    o, _ = env.reset(seed=100 + e)
    tot, fine = 0.0, False
    while not fine:
        with torch.no_grad():
            o, r, te, tr, _ = env.step(int(attore(torch.tensor(o, dtype=torch.float32)).argmax()))
        tot += r; fine = te or tr
    punti.append(tot)
print("valutazione su 10 episodi:", np.mean(punti))
torch.save(attore.state_dict(), "esperto.pt")           # servirà come esperto da imitare

In meno di 10 secondi su un processore (circa 100 000 passi) il ritorno medio degli ultimi 20 episodi durante l’addestramento passa da 162 dopo 20 aggiornamenti a 245, 201, 401 e 407 (durante l’addestramento le azioni sono ancora casuali), e la valutazione con l’azione più probabile dà 500 su tutti e 10 gli episodi. Due accorgimenti sono stati decisivi nelle prove: il passo di apprendimento che scende a zero e il limite alla norma del gradiente (0,5); senza, la policy raggiungeva 400 e poi peggiorava. È un comportamento tipico dell’RL: i risultati cambiano molto con gli iperparametri e con il seme casuale, e per confrontare due metodi si usano sempre più semi. Una semplificazione: il codice tratta come finale anche il tempo scaduto (500 passi), che a rigore richiederebbe di continuare il bootstrap; per CartPole l’effetto è piccolo.

Perché il PPO è ovunque

È semplice (poche righe oltre l’actor-critic), robusto agli iperparametri più di molti concorrenti, funziona con azioni discrete e continue e si parallelizza bene su migliaia di ambienti simulati. È l’algoritmo con cui OpenAI addestrò la mano robotica a risolvere il cubo di Rubik e i bot di Dota 2, e quello usato nel 2022 per InstructGPT, il modello linguistico che rese famoso l’addestramento con il feedback umano (RLHF), come si vede nella lezione sulle applicazioni.

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x