Vai al contenuto
Apprendimento per Rinforzo

Il Deep Q-Network (DQN): memoria di replay, rete bersaglio e double DQN su CartPole

Q-learning con una rete neurale: la triade letale, memoria di replay, rete bersaglio, la sovrastima del massimo dimostrata e il double DQN, fine vera e tempo scaduto, codice PyTorch che arriva a 500 su CartPole.

Una tabella Q(s, a) funziona finché gli stati sono pochi. CartPole ha quattro variabili continue, un videogioco Atari ha immagini di 210×160 pixel: gli stati sono infiniti o astronomicamente numerosi. Nel 2015 DeepMind mostrò che una rete neurale convoluzionale, addestrata con Q-learning e due accorgimenti, impara a giocare a 49 giochi Atari partendo solo dai pixel, superando in molti il livello umano. È il Deep Q-Network (DQN). Questa lezione spiega perché il Q-learning con una rete neurale è instabile, come lo stabilizzano la memoria di replay e la rete bersaglio, perché il massimo sovrastima i valori (e il rimedio, double DQN), e lo applica a CartPole con un codice che arriva al punteggio massimo.

Dal Q-learning con tabella alla rete

Si sostituisce la tabella con una funzione Q_\theta(s, a) dai parametri \theta (i pesi di una rete): in ingresso lo stato, in uscita un valore per ogni azione. L’aggiornamento del Q-learning diventa un passo di discesa del gradiente sulla perdita

    \[ L(\theta) = \Big(\underbrace{r + \gamma\max_{a'}Q_{\bar\theta}(s', a')}_{y,\ \text{bersaglio}} - Q_\theta(s, a)\Big)^2 \]

dove il bersaglio y viene trattato come una costante (non si deriva rispetto a esso). Sembra semplice, ma messo insieme in modo ingenuo diverge spesso.

Perché diverge: la triade letale

Sutton e Barto chiamano triade letale la combinazione di tre ingredienti che, insieme, possono far divergere l’apprendimento anche in problemi semplici:

  • approssimazione di funzione: cambiando \theta per correggere Q(s, a) cambiano anche i valori di stati simili, che non si volevano toccare;
  • bootstrap: il bersaglio contiene la stima stessa Q(s', \cdot), quindi un errore si propaga e si autoalimenta;
  • off-policy: si impara la policy greedy da dati raccolti con un’altra policy (quella che esplora).

Il Q-learning profondo li ha tutti e tre. A questi si aggiunge un problema statistico: i passi consecutivi di un episodio sono fortemente correlati (lo stato al passo t + 1 è quasi uguale a quello al passo t), mentre la discesa del gradiente presuppone esempi indipendenti.

I due accorgimenti del DQN

La memoria di replay. Ogni transizione (s, a, r, s', \text{fine}) viene salvata in un buffer di qualche decina di migliaia di elementi; a ogni passo di addestramento si estrae a caso un mini-lotto. Questo rompe le correlazioni, riusa ogni esperienza molte volte (ed è lecito perché il Q-learning è off-policy) e media su molte situazioni diverse.

La rete bersaglio. Il bersaglio y si calcola con una copia dei pesi, \bar\theta, aggiornata solo ogni qualche centinaio o migliaio di passi (\bar\theta \leftarrow \theta). Tra due copie il problema diventa una regressione supervisionata con bersagli fissi, e la rete non “insegue la propria coda”.

Lo schema del DQN
L’agente agisce nell’ambiente con una policy epsilon-greedy basata sulla rete Q; le transizioni finiscono nella memoria di replay, da cui si estraggono mini-lotti casuali. La perdita confronta Q theta con il bersaglio calcolato dalla rete bersaglio, una copia ritardata dei pesi.

La sovrastima del massimo e il double DQN

Teorema. Se le stime \hat Q(s', a') sono non distorte ma rumorose, \mathbb E[\hat Q(s', a')] = Q(s', a'), allora il massimo è sovrastimato: \mathbb E[\max_{a'}\hat Q(s', a')] \ge \max_{a'}Q(s', a').

Dimostrazione. Per ogni a', \max_b\hat Q(s', b) \ge \hat Q(s', a'); prendendo la media, \mathbb E[\max_b\hat Q] \ge \mathbb E[\hat Q(s', a')] = Q(s', a'), e vale per ogni a', quindi anche per quello che rende massimo Q. ∎

Il rumore viene “selezionato”: l’azione che sembra migliore è spesso quella stimata per eccesso. La sovrastima si accumula con il bootstrap. Il double DQN (van Hasselt, 2016) separa la scelta dalla valutazione: la rete online sceglie l’azione, la rete bersaglio la valuta,

    \[ y = r + \gamma\,Q_{\bar\theta}\big(s',\ \arg\max_{a'}Q_\theta(s', a')\big) \]

Gli errori delle due reti sono in buona parte indipendenti, e la sovrastima si riduce molto.

Fine vera e tempo scaduto. Se l’episodio finisce perché il palo è caduto, dopo non c’è più ricompensa e il bersaglio è solo r. Se invece finisce perché è scaduto il tempo (500 passi in CartPole), lo stato non è terminale: il bersaglio deve comprendere ancora \gamma\max Q(s', \cdot). Confondere i due casi è uno degli errori più comuni; Gymnasium li distingue con i due valori terminated e truncated.

Il codice su CartPole

import random, collections
import numpy as np
import torch, torch.nn as nn
import gymnasium

torch.manual_seed(0); random.seed(0); np.random.seed(0)
env = gymnasium.make("CartPole-v1")

def rete():
    return nn.Sequential(nn.Linear(4, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 2))
q, q_bersaglio = rete(), rete()
q_bersaglio.load_state_dict(q.state_dict())
opt = torch.optim.Adam(q.parameters(), lr=2.5e-4)
memoria = collections.deque(maxlen=10000)                     # memoria di replay
PASSI, LOTTO, GAMMA, COPIA, OGNI, INIZIO = 200000, 128, 0.99, 500, 10, 10000

o, _ = env.reset(seed=0)
env.action_space.seed(0)
for t in range(PASSI):
    eps = max(0.05, 1 - 0.95 * t / 100000)                   # esplorazione che decresce
    if random.random() < eps:
        a = env.action_space.sample()
    else:
        with torch.no_grad():
            a = int(q(torch.tensor(o, dtype=torch.float32)).argmax())
    o2, r, terminato, troncato, _ = env.step(a)
    memoria.append((o, a, r, o2, float(terminato)))           # solo la fine vera azzera il futuro
    o = o2
    if terminato or troncato:
        o, _ = env.reset()
    if t >= INIZIO and t % OGNI == 0:
        b = random.sample(memoria, LOTTO)
        O, A, R, O2, D = (torch.tensor(np.array(x), dtype=torch.float32) for x in zip(*b))
        with torch.no_grad():                                 # double DQN
            a2 = q(O2).argmax(1, keepdim=True)
            y = R + GAMMA * (1 - D) * q_bersaglio(O2).gather(1, a2).squeeze(1)
        stima = q(O).gather(1, A.long().unsqueeze(1)).squeeze(1)
        perdita = nn.functional.mse_loss(stima, y)
        opt.zero_grad(); perdita.backward(); opt.step()
    if t % COPIA == 0:
        q_bersaglio.load_state_dict(q.state_dict())          # aggiornamento della rete bersaglio

# valutazione greedy su 20 episodi
punti = []
for e in range(20):
    o, _ = env.reset(seed=1000 + e)
    tot, fine = 0.0, False
    while not fine:
        with torch.no_grad():
            o, r, te, tr, _ = env.step(int(q(torch.tensor(o, dtype=torch.float32)).argmax()))
        tot += r; fine = te or tr
    punti.append(tot)
print("valutazione:", np.mean(punti))

Gli iperparametri sono quelli della libreria didattica CleanRL. Durante l’addestramento il ritorno medio passa da 62 (dopo 40 000 passi) a 353, 370, 468 e 500; la valutazione greedy finale è 500 su 20 episodi, il massimo possibile. Su un processore normale serve meno di un minuto.

Apprendimento del DQN su CartPole
Il ritorno medio degli ultimi 20 episodi cresce da circa 60 dopo 40 mila passi fino al massimo di 500 a 200 mila passi; la linea grigia tratteggiata è il punteggio massimo.

Oltre il DQN

Il DQN funziona con poche azioni discrete (deve calcolare il massimo su tutte). Per azioni continue, come le coppie di un robot o lo sterzo di un’auto, si usano i metodi actor-critic (DDPG, TD3, SAC), che imparano anche una policy, e i metodi a gradiente della policy della lezione successiva. Le varianti del DQN per i giochi (Rainbow) aggiungono il replay con priorità, le reti “dueling”, i ritorni a più passi e le distribuzioni dei ritorni; il principio resta quello descritto qui.

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