Nella lezione precedente, Gymnasium, Taxi e FrozenLake, la tabella funzionava benissimo perché gli stati erano pochi: 500 nel Taxi, 16 nel FrozenLake. Ora il problema cambia natura. In CartPole lo stato è fatto di quattro numeri reali, e fra due valori reali ce ne sono sempre infiniti altri: una tabella con una riga per stato non si può nemmeno scrivere. La Deep Q-Learning (DQN, Mnih e colleghi) sostituisce la tabella con una rete neurale che stima Q(s, a) per qualunque stato, anche mai visto. Il prezzo è che l'addestramento diventa instabile, e questa lezione lo misura: costruisci un DQN completo, togli uno alla volta i due accorgimenti che lo tengono in piedi, e guardi che cosa succede quando lo metti in un simulatore con la fisica cambiata.
Una precisazione sul metodo, perché i numeri di questa pagina sono pochi e vanno letti per quello che sono. Ogni addestramento DQN qui dura 20.000 passi, con tre semi per configurazione, su CPU e con un numero di thread fissato. Sono esperimenti piccoli, scelti perché si possano rifare in pochi minuti ciascuno, non una valutazione definitiva: dove la differenza fra due configurazioni è dentro la variabilità fra semi, il testo lo dice e non trae conclusioni. Una versione precedente di questa lezione riportava tabelle su 40.000 passi con più configurazioni; quei numeri non sono stati riprodotti e sono stati tolti, perché le uscite che leggi qui sono tutte quelle di listati rieseguiti.
CartPole in breve
CartPole è un carrello su un binario con un'asta incernierata sopra. L'osservazione è un vettore di quattro numeri reali (posizione e velocità del carrello, angolo e velocità angolare dell'asta), le azioni sono due (spingere a sinistra o a destra) e si riceve +1 per ogni passo, compreso quello in cui l'episodio termina. Secondo la documentazione di Gymnasium l'episodio termina (terminated) se l'asta supera ±12° (0,2095 radianti) o il centro del carrello esce da ±2,4, ed è troncato (truncated) quando supera i 500 passi: 500 è quindi il massimo ritorno.
Dalla lezione sul Q-Learning ricordi la distinzione fra i due modi di finire un episodio: il bootstrap si azzera solo per terminated, mai per un troncamento dovuto al tempo, perché lo stato dopo il troncamento non è davvero senza futuro. Il codice sotto la rispetta salvando nel buffer solo terminated. Il resto del vocabolario serve man mano: il replay buffer è un archivio circolare delle ultime transizioni da cui si pescano gruppi casuali; la rete target è una copia congelata della rete, aggiornata ogni tanto, che calcola il bersaglio; la perdita di Huber è quadratica per errori piccoli e lineare per quelli grandi, quindi meno sensibile ai valori anomali dell'errore quadratico.
Il limite della tabella, misurato
Prima di inventare una rete, vale la pena provare il rimedio ovvio: tenere la tabella e discretizzare lo stato, cioè dividere ogni numero reale in intervalli e usare la combinazione dei quattro intervalli come numero di riga. Il listato sotto usa il Q-Learning della lezione su Q-Learning e SARSA con 3, 6, 10 e 16 intervalli per dimensione, quindi tabelle da 81 fino a 65.536 righe, e riporta il ritorno medio degli ultimi 200 episodi di addestramento (con ε ancora a 0,05, quindi non è una valutazione greedy).
import numpy as np
import gymnasium as gym
# What if we keep the TABLE and chop the continuous state into bins?
def make_binner(bins_per_dim):
low = np.array([-2.4, -3.0, -0.21, -3.5]); high = -low # cart position, cart velocity, pole angle, pole angular velocity
edges = [np.linspace(l, h, bins_per_dim + 1)[1:-1] for l, h in zip(low, high)]
def to_state(obs):
idx = [int(np.digitize(o, e)) for o, e in zip(obs, edges)]
s = 0
for i in idx:
s = s * bins_per_dim + i
return s
return to_state, bins_per_dim ** 4
def tabular_cartpole(bins, episodes=3000, alpha=0.1, gamma=0.99, seed=0):
to_state, n_states = make_binner(bins)
env = gym.make("CartPole-v1"); rng = np.random.default_rng(seed)
Q = np.zeros((n_states, 2)); returns = []
for ep in range(episodes):
eps = max(0.05, 1.0 - ep / (episodes / 2))
obs, _ = env.reset(seed=int(rng.integers(1 << 30))); s = to_state(obs); total, done = 0.0, False
while not done:
a = int(rng.integers(2)) if rng.random() < eps else int(np.argmax(Q[s]))
obs, r, terminated, truncated, _ = env.step(a); s2 = to_state(obs)
target = r + (0.0 if terminated else gamma * Q[s2].max())
Q[s, a] += alpha * (target - Q[s, a]); s = s2; total += r; done = terminated or truncated
returns.append(total)
return np.mean(returns[-200:]), n_states
for bins in (3, 6, 10, 16):
m, n = tabular_cartpole(bins)
print(f"{bins:2d} bins per dimension -> {n:6d} table rows: mean return over the last 200 training episodes = {m:6.1f}")
# 3 bins per dimension -> 81 table rows: mean return over the last 200 training episodes = 111.5
# 6 bins per dimension -> 1296 table rows: mean return over the last 200 training episodes = 132.4
# 10 bins per dimension -> 10000 table rows: mean return over the last 200 training episodes = 168.7
# 16 bins per dimension -> 65536 table rows: mean return over the last 200 training episodes = 142.9
Come leggere il codice: make_binner definisce per ogni dimensione gli estremi plausibili e bins − 1 soglie equispaziate; np.digitize assegna ogni valore al suo intervallo e le quattro etichette si combinano in un indice in base bins, quindi la tabella ha bins⁴ righe. L'uscita dice due cose. Nessuna tabella arriva vicino a 500: il massimo è 169, con 10.000 righe. E infittire troppo non aiuta: da 10.000 a 65.536 righe il valore scende da 169 a 143, perché più righe significano meno visite per riga, dati più radi. Con pochi intervalli stati diversi si confondono nella stessa cella; con molti ogni cella impara troppo poco. È il dilemma che la rete aggira generalizzando, perché stati vicini condividono i pesi. Il listato è stato eseguito due volte con uscita identica.
Dalla tabella alla rete: la perdita
La rete Q(s, a; θ) riceve lo stato e restituisce un valore per ogni azione (due numeri in CartPole). La regola del Q-Learning a tabella, Q(s, a) ← Q(s, a) + α [ r + γ max Q(s′, ·) − Q(s, a) ], è un passo di discesa del gradiente su un errore quadratico in cui il bersaglio è trattato come una costante. Con una rete si fa lo stesso: si definisce la perdita sotto, e si fa un passo di gradiente solo su θ, senza derivare il bersaglio. Questo si chiama semi-gradiente: se derivassi anche attraverso y, la stima e il bersaglio si muoverebbero insieme e il gradiente non rappresenterebbe più l'errore che vuoi ridurre.
L(θ) = ( y − Q(s, a; θ) )² con y = r + γ · max_{a′} Q(s′, a′; θ⁻) (y = r se s′ è terminale)
Il simbolo θ⁻ indica che il bersaglio è calcolato con una copia congelata dei pesi, la rete target, e non con θ: fra poco si vede perché. In pratica l'errore quadratico si sostituisce con la perdita di Huber, che qui è smooth_l1_loss di PyTorch. Nota anche cosa manca: nella tabella ogni riga si aggiorna da sola, mentre qui un solo passo di gradiente sposta i pesi condivisi da tutti gli stati. È la fonte di tutto ciò che segue.
Perché la versione ingenua non funziona
Prendere la formula e addestrare la rete passo dopo passo sull'ultima transizione, con θ⁻ = θ, sembra naturale e incontra due problemi distinti, che il DQN risolve con due accorgimenti distinti. Sutton e Barto, al paragrafo 11.3 del loro libro, li inquadrano come triade mortale: l'instabilità e la divergenza possono comparire quando si combinano approssimazione di funzioni, bootstrapping (bersagli che contengono stime esistenti) e addestramento off-policy. Il DQN li ha tutti e tre. Il libro aggiunge che con due elementi su tre l'instabilità si può evitare, ma rinunciare all'approssimazione non è realistico, come hai visto qui sopra con la tabella.
Primo problema: campioni correlati. I dati di una traiettoria sono consecutivi e simili: se l'asta sta cadendo a destra, le dieci transizioni successive raccontano la stessa cosa, e una rete addestrata su un lungo tratto così tende a dimenticare il resto. Il rimedio è il replay buffer: si archiviano le ultime 50.000 transizioni e a ogni passo si addestra su un gruppo casuale di 64, che contiene momenti molto diversi. L'idea di campionare a caso fra transizioni passate è nell'articolo del 2013 di Mnih e colleghi (Playing Atari with Deep Reinforcement Learning), che la presenta proprio per attenuare dati correlati e distribuzioni non stazionarie.
Secondo problema: il bersaglio che insegue sé stesso. Nel Q-Learning a tabella, aggiornare una cella cambia il bersaglio di un'altra riga solo se le due sono collegate. Con una rete ogni passo cambia i pesi, e quindi cambia contemporaneamente la stima Q(s, a; θ) e il bersaglio y, che dipende da Q(s′, ·) calcolata con gli stessi pesi. È come inseguire un bersaglio che si sposta a ogni tuo movimento. Il rimedio è la rete target: i pesi θ⁻ si copiano da θ solo ogni N passi (qui 500) e fra una copia e l'altra il bersaglio è fermo. La rete target compare nell'articolo su Nature del 2015, che nei dati estesi ha anche una tabella sugli effetti di replay e rete target separata sui giochi Atari: qui rifai l'esperimento su CartPole.
Il codice completo
Ecco il DQN per intero: replay buffer, rete e ciclo di addestramento, più la funzione di valutazione. Contiene tre interruttori (use_replay, use_target, double) che servono alle misure. C'è anche un dettaglio che manca in molte versioni in circolazione ed è la causa di uscite non ripetibili: env.action_space.seed(seed). Senza di esso action_space.sample(), usata per l'esplorazione, pesca da un generatore che non dipende dal tuo seme, e due esecuzioni identiche danno risultati diversi. Torch, NumPy, random, ambiente e spazio delle azioni sono tutti seminati, e il numero di thread di PyTorch è fisso a 2.
import copy
import random
from collections import deque
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import gymnasium as gym
torch.set_num_threads(2) # fixed number of threads: the machine used for the printed numbers was shared with other jobs
class ReplayBuffer:
"""Stores the last `capacity` transitions; sampling a random batch breaks the correlation between consecutive steps."""
def __init__(self, capacity, obs_dim):
self.s = np.zeros((capacity, obs_dim), np.float32); self.s2 = np.zeros((capacity, obs_dim), np.float32)
self.a = np.zeros(capacity, np.int64); self.r = np.zeros(capacity, np.float32); self.done = np.zeros(capacity, np.float32)
self.capacity, self.size, self.pos = capacity, 0, 0
def add(self, s, a, r, s2, terminated):
i = self.pos
self.s[i], self.a[i], self.r[i], self.s2[i], self.done[i] = s, a, r, s2, float(terminated)
self.pos = (self.pos + 1) % self.capacity
self.size = min(self.size + 1, self.capacity)
def sample(self, batch):
idx = np.random.randint(0, self.size, batch)
return (torch.from_numpy(self.s[idx]), torch.from_numpy(self.a[idx]), torch.from_numpy(self.r[idx]),
torch.from_numpy(self.s2[idx]), torch.from_numpy(self.done[idx]))
class QNetwork(nn.Module):
"""Q(s, .) for ALL actions at once: input = the 4 numbers of the CartPole state, output = one value per action."""
def __init__(self, obs_dim, n_actions, hidden=128):
super().__init__()
self.net = nn.Sequential(nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, n_actions))
def forward(self, x):
return self.net(x)
def dqn(steps=40_000, use_replay=True, use_target=True, double=False, gamma=0.99, lr=1e-3, batch=64,
buffer_size=50_000, learn_start=1_000, target_every=500, eps_end=0.05, eps_decay_steps=10_000,
seed=0, env_factory=None, eval_every=None, eval_fn=None, keep_best=False):
torch.manual_seed(seed); np.random.seed(seed); random.seed(seed)
env = env_factory() if env_factory else gym.make("CartPole-v1")
obs_dim, n_actions = env.observation_space.shape[0], env.action_space.n
q, q_target = QNetwork(obs_dim, n_actions), QNetwork(obs_dim, n_actions)
q_target.load_state_dict(q.state_dict())
opt = torch.optim.Adam(q.parameters(), lr=lr)
buf = ReplayBuffer(buffer_size, obs_dim)
env.action_space.seed(seed) # seed of action_space.sample(), otherwise the output is not reproducible
s, _ = env.reset(seed=seed)
episode_return, episode_returns, checkpoints = 0.0, [], []
best_score, best_state = -1.0, None
for t in range(1, steps + 1):
eps = max(eps_end, 1.0 - (1.0 - eps_end) * t / eps_decay_steps)
if random.random() < eps:
a = env.action_space.sample()
else:
with torch.no_grad():
a = int(q(torch.from_numpy(s).float().unsqueeze(0)).argmax())
s2, r, terminated, truncated, _ = env.step(a)
buf.add(s, a, r, s2, terminated) # `terminated` only: a time-limit truncation is not a real end
s, episode_return = s2, episode_return + r
if terminated or truncated:
episode_returns.append(episode_return); episode_return = 0.0
s, _ = env.reset()
if t >= learn_start:
if use_replay:
bs, ba, br, bs2, bd = buf.sample(batch)
else: # no replay: learn only from the LAST transition
bs, ba, br, bs2, bd = [torch.from_numpy(x[(buf.pos - 1) % buf.capacity][None]) for x in (buf.s, buf.a, buf.r, buf.s2, buf.done)]
with torch.no_grad():
net_for_target = q_target if use_target else q
if double: # Double DQN: the online net CHOOSES the action, the target net EVALUATES it
best = q(bs2).argmax(dim=1, keepdim=True)
next_q = net_for_target(bs2).gather(1, best).squeeze(1)
else:
next_q = net_for_target(bs2).max(dim=1).values
target = br + gamma * (1.0 - bd) * next_q
current = q(bs).gather(1, ba.unsqueeze(1)).squeeze(1)
loss = F.smooth_l1_loss(current, target) # Huber loss: gentler than MSE for large errors
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(q.parameters(), 10.0)
opt.step()
if use_target and t % target_every == 0:
q_target.load_state_dict(q.state_dict()) # periodic hard copy of the weights
if eval_every and t % eval_every == 0 and eval_fn:
score = eval_fn(q)
checkpoints.append((t, score))
if score > best_score: # remember the best checkpoint (early stopping on a validation score)
best_score, best_state = score, copy.deepcopy(q.state_dict())
if keep_best and best_state is not None:
q.load_state_dict(best_state)
return q, episode_returns, checkpoints
def evaluate_greedy(q, episodes=30, seed=10_000, env_factory=None):
env = env_factory() if env_factory else gym.make("CartPole-v1")
totals = []
for i in range(episodes):
s, _ = env.reset(seed=seed + i); done, total = False, 0.0
while not done:
with torch.no_grad():
a = int(q(torch.from_numpy(s).float().unsqueeze(0)).argmax())
s, r, terminated, truncated, _ = env.step(a); total += r; done = terminated or truncated
totals.append(total)
return float(np.mean(totals))
Percorri il codice dall'alto. ReplayBuffer è un archivio circolare di cinque array (stato, azione, ricompensa, stato successivo, flag di fine): add scrive nella posizione corrente e avanza il puntatore con % capacity, sovrascrivendo i più vecchi quando è pieno; sample estrae indici a caso. Si salva float(terminated), non terminated or truncated, per la ragione vista sopra. QNetwork è un perceptron a due strati nascosti da 128 unità (4 → 128 → 128 → 2) che restituisce Q(s, sinistra) e Q(s, destra) in un colpo solo, quindi per scegliere l'azione basta l'argmax dell'uscita.
Nel ciclo, ε scende linearmente da 1 a 0,05 nei primi 10.000 passi; dopo learn_start = 1000 passi, per avere un po' di dati, a ogni passo si pesca un minibatch di 64 e si calcola il bersaglio dentro torch.no_grad(), ossia senza gradiente: target = r + γ (1 − done) next_q, dove (1 − done) annulla il futuro per le transizioni terminali. La stima è q(bs).gather(1, ba), il valore dell'azione effettivamente eseguita; la perdita è smooth_l1_loss; il gradiente viene limitato a norma 10 prima del passo di Adam, per evitare passi enormi quando l'errore è grande. Ogni target_every passi si copia la rete online in quella target.
Gli interruttori modificano ciascuno una riga. use_replay=False sostituisce il minibatch con la sola ultima transizione; use_target=False calcola il bersaglio con la rete online; double=True fa scegliere l'azione alla rete online e valutarla alla rete target, come spiegato più avanti. evaluate_greedy è il protocollo di valutazione della lezione precedente: ε = 0, episodi nuovi con semi mai usati nell'addestramento, ritorno medio. Un avvertimento sulla riga keep_best: serve più avanti a conservare il miglior checkpoint, e il checkpoint migliore è scelto su una misura rumorosa, quindi è un criterio utile ma non innocente.
L'ablazione: togliere uno dei due accorgimenti
Un'ablazione toglie un pezzo alla volta per vedere che cosa cambia. Le quattro configurazioni sono: DQN completo, senza rete target, senza replay buffer, senza nessuno dei due. Per ciascuna, tre semi, 20.000 passi e una valutazione greedy di 20 episodi ogni 5.000 passi, quindi quattro checkpoint per addestramento e 12 per configurazione. I 12 addestramenti hanno richiesto un tempo che dipende dal carico della macchina e non lo riporto; l'ordine di grandezza era di pochi minuti ciascuno, su CPU con due thread.
# The ablation: the same training run with replay and/or target network switched off.
# 3 seeds, 20 000 steps, a greedy evaluation (20 episodes, epsilon = 0) every 5 000 steps.
configs = {
"DQN (replay + target)": dict(use_replay=True, use_target=True),
"no target network": dict(use_replay=True, use_target=False),
"no replay buffer": dict(use_replay=False, use_target=True),
"neither": dict(use_replay=False, use_target=False),
}
for name, kwargs in configs.items():
for seed in range(3):
q, er, cp = dqn(steps=20_000, seed=seed, eval_every=5_000, eval_fn=lambda q: evaluate_greedy(q, 20), **kwargs)
sc = [round(c[1]) for c in cp]
print(f"{name:22s} seed {seed}: {sc}", flush=True)
# DQN (replay + target) seed 0: [143, 88, 123, 123]
# DQN (replay + target) seed 1: [225, 276, 168, 143]
# DQN (replay + target) seed 2: [115, 83, 110, 130]
# no target network seed 0: [9, 9, 9, 9]
# no target network seed 1: [9, 9, 9, 9]
# no target network seed 2: [9, 9, 9, 9]
# no replay buffer seed 0: [17, 11, 11, 9]
# no replay buffer seed 1: [50, 16, 62, 10]
# no replay buffer seed 2: [15, 10, 9, 9]
# neither seed 0: [9, 9, 9, 9]
# neither seed 1: [9, 9, 9, 9]
# neither seed 2: [9, 9, 9, 9]
#
#
# configuration mean median ckpt <= 15 ckpt >= 400
# DQN (replay + target) 143.9 126.5 0.0% 0.0%
# no target network 9.0 9.0 100.0% 0.0%
# no replay buffer 19.1 11.0 66.7% 0.0%
# neither 9.0 9.0 100.0% 0.0%
Il codice scorre le configurazioni e stampa, per ogni seme, il ritorno medio della politica greedy ai quattro checkpoint; la tabella in coda non è stampata dal listato: l'ho calcolata a parte, con un breve script, dalle stesse 12 valutazioni per configurazione. La lettura è netta, almeno per il caso estremo. Senza rete target il ritorno è 9 in ogni checkpoint di ogni seme, e lo stesso vale per la configurazione che toglie entrambe. Il DQN completo non collassa mai (nessun checkpoint ≤ 15) ma non arriva a 500: il massimo osservato è 276 e la media 144. Senza replay è fra i due: media 19, e il 66,7% dei checkpoint è ≤ 15.
Che cosa significa 9? Ho controllato nel caso senza rete target, seme 0, con una run separata a 20.000 passi: la politica greedy sceglie sempre l'azione 1 (spingere a destra) e l'asta cade dopo 9 passi. I valori Q stimati nelle quattro situazioni provate sono dell'ordine di 5 · 10⁷, mentre il ritorno scontato massimo possibile con γ = 0,99 su 500 passi è (1 − 0,99⁵⁰⁰)/0,01 ≈ 99,3. È una divergenza vera, non un valore un po' sbagliato: il bersaglio inseguiva sé stesso, i valori sono esplosi e le due uscite sono diventate quasi uguali. La verifica riguarda un solo seme: negli altri due vedo lo stesso ritorno 9, non ho ispezionato le loro reti. Per il caso senza replay non ho misurato la causa.
Conclusione pratica: nella configurazione provata la rete target è la differenza fra un agente che non impara e uno che impara, e il replay è la differenza fra un agente che impara molto poco e uno che impara. Ma il DQN completo che hai appena visto è tutt'altro che un agente risolto: ritorno medio 144 su un massimo di 500, con valori che oscillano da un checkpoint all'altro. Il punto è che l'assenza di collasso non è convergenza, e il prossimo esperimento serve a vederlo.
Sovrastima e Double DQN
C'è un difetto statistico noto nel bersaglio r + γ max Q(s′, ·): il massimo di stime rumorose è in media più grande del massimo dei valori veri. Se tutte le azioni valgono 0 ma le stime sono rumorose, la più alta delle stime è positiva. Lo si misura meglio in un caso in cui la verità è nota: stime gaussiane con deviazione 1 attorno a 0, per 2, 5 e 10 azioni, 200.000 prove ciascuna. Il rimedio proposto da van Hasselt e colleghi nel 2015 (Double Q-learning applicato al DQN) è scegliere l'azione con una stima e valutarla con un'altra, indipendente.
import numpy as np
rng = np.random.default_rng(0)
# All actions are EQUALLY good (true value 0), but our estimates are noisy (standard deviation 1).
for n_actions in (2, 5, 10):
noisy = rng.normal(0, 1, (200_000, n_actions)) # the estimates Q(s', a') for each action
other = rng.normal(0, 1, (200_000, n_actions)) # an INDEPENDENT second set of estimates
max_estimate = noisy.max(axis=1).mean() # what DQN uses: max_a Q(s', a)
double_estimate = other[np.arange(len(other)), noisy.argmax(axis=1)].mean() # Double DQN: pick with one set, evaluate with the other
print(f"{n_actions:2d} actions: E[max of noisy estimates] = {max_estimate:5.2f} | pick with one, evaluate with the other = {double_estimate:5.2f} (truth: 0)")
# 2 actions: E[max of noisy estimates] = 0.57 | pick with one, evaluate with the other = -0.00 (truth: 0)
# 5 actions: E[max of noisy estimates] = 1.16 | pick with one, evaluate with the other = -0.00 (truth: 0)
# 10 actions: E[max of noisy estimates] = 1.54 | pick with one, evaluate with the other = -0.00 (truth: 0)
Il bias del massimo cresce con il numero di azioni (0,57, 1,16, 1,54) e il metodo «scegli con una, valuta con l'altra» lo azzera (−0,00, cioè zero entro il rumore di campionamento). Nel DQN le due stime sono le due reti già presenti: la rete online sceglie a* = argmax Q(s′, ·; θ) e la rete target ne valuta il valore Q(s′, a*; θ⁻), ed è il ramo double=True del codice. Le stime della rete online e della rete target non sono indipendenti come nella simulazione (la target è una copia vecchia della online), quindi l'effetto reale è probabilmente più debole di quello ideale. L'abstract dell'articolo di van Hasselt afferma che il DQN soffre di sovrastime sostanziali in alcuni giochi Atari: qui si verifica su CartPole.
Instabilità e sovrastima su CartPole
Il listato definisce overestimation, che confronta, per 30 episodi, il valore Q(s0, a*) previsto dalla rete per lo stato iniziale con il ritorno scontato realmente ottenuto seguendo la politica greedy, e poi lo applica a due configurazioni con passo di apprendimento 5e-4: DQN standard e Double DQN, tre semi ciascuna. Il passo dimezzato rispetto a 10⁻³ è quello che la versione precedente di questa lezione indicava come più stabile: qui si controlla se regge.
# Does the network OVERESTIMATE? Compare what it predicts for the start state with what the greedy policy really collects.
@torch.no_grad()
def overestimation(q, episodes=30, gamma=0.99, seed=20_000):
env = gym.make("CartPole-v1")
predicted, realised = [], []
for i in range(episodes):
s, _ = env.reset(seed=seed + i)
predicted.append(q(torch.from_numpy(s).float().unsqueeze(0)).max().item()) # Q(s0, best action)
rewards, done = [], False
while not done:
a = int(q(torch.from_numpy(s).float().unsqueeze(0)).argmax())
s, r, terminated, truncated, _ = env.step(a); rewards.append(r); done = terminated or truncated
G = 0.0
for r in reversed(rewards):
G = r + gamma * G # the discounted return that was REALLY obtained
realised.append(G)
return np.mean(predicted), np.mean(realised)
configs = {
"lr 5e-4": dict(lr=5e-4),
"lr 5e-4, Double DQN": dict(lr=5e-4, double=True),
}
for name, kwargs in configs.items():
for seed in range(3):
q, er, cp = dqn(steps=20_000, seed=seed, eval_every=5_000, eval_fn=lambda q: evaluate_greedy(q, 20), **kwargs)
sc = [round(c[1]) for c in cp]
print(f"{name:22s} seed {seed}: {sc}", flush=True)
p, r = overestimation(q)
print(f" overestimation {name} seed {seed}: predicted {p:.1f} realised {r:.1f}", flush=True)
# lr 5e-4 seed 0: [239, 184, 129, 141]
# overestimation lr 5e-4 seed 0: predicted 33.7 realised 75.5
# lr 5e-4 seed 1: [219, 117, 125, 104]
# overestimation lr 5e-4 seed 1: predicted 34.5 realised 64.6
# lr 5e-4 seed 2: [189, 145, 97, 99]
# overestimation lr 5e-4 seed 2: predicted 34.1 realised 63.1
# lr 5e-4, Double DQN seed 0: [173, 443, 152, 106]
# overestimation lr 5e-4, Double DQN seed 0: predicted 32.8 realised 65.4
# lr 5e-4, Double DQN seed 1: [120, 90, 106, 109]
# overestimation lr 5e-4, Double DQN seed 1: predicted 33.4 realised 66.3
# lr 5e-4, Double DQN seed 2: [197, 115, 103, 109]
# overestimation lr 5e-4, Double DQN seed 2: predicted 34.0 realised 67.4
Prima lettura, onesta: la sovrastima non compare. Il valore previsto per lo stato iniziale è circa 34 in tutte e sei le reti, mentre il ritorno scontato realmente ottenuto è 63-76 (media 68 per il DQN standard, 66 per il Double DQN). La rete sottostima, probabilmente perché a 20.000 passi non ha ancora esteso i valori verso gli episodi lunghi (non ho verificato questa spiegazione); il bias del massimo è reale (lo hai misurato sopra) ma qui è dominato da altri errori. E le due varianti non si distinguono: non ho dimostrato che il Double DQN serva in questo problema, né che non serva su problemi più difficili o addestramenti più lunghi, dove la letteratura lo documenta. Costa una riga e non ha fatto danni: una scelta ragionevole come default, non un risultato di questa misura.
Seconda lettura, sulla stabilità. La media dei 12 checkpoint è 149 per il DQN standard e 152 per il Double DQN, contro 144 con il passo 10⁻³ dell'ablazione: differenze molto dentro il rumore di tre semi, quindi non posso dire che dimezzare il passo aiuti, come invece sosteneva la versione precedente. Ciò che invece si vede in tutte e tre le configurazioni è che il primo checkpoint (5.000 passi) è spesso il migliore: media del primo checkpoint 161 (passo 10⁻³), 216 (passo 5e-4), 163 (Double) contro 132, 115 e 108 dell'ultimo. Il Double DQN con seme 0 mostra anche il tipico salto: ritorno 173, poi 443, poi 152 e 106, senza che alcun parametro sia cambiato fra un checkpoint e l'altro.
Perché la politica peggiora mentre continua ad addestrarsi? Una spiegazione plausibile, che non ho verificato isolando il buffer: una politica migliore riempie il buffer di episodi lunghi e facili, i casi in cui l'asta cade escono dal buffer, la rete li dimentica e la politica successiva peggiora. Il meccanismo è quello della triade mortale citata sopra, in una forma blanda: nessuna divergenza esplosiva, ma nemmeno un miglioramento monotono. Per questo in pratica si valuta a intervalli e si conserva il miglior checkpoint invece dell'ultimo, si addestra con più semi e si riportano media e dispersione. Non ho provato qui la copia «morbida» della rete target (θ⁻ ← τθ + (1 − τ)θ⁻) né il prioritized replay: sono rimedi citati, non misurati.
Il simulatore diverso: overfitting e randomizzazione del dominio
Chiudiamo con l'avvertimento della lezione su Gymnasium e overfitting, ora con una rete. Un DQN addestrato su CartPole con le costanti standard (mezza lunghezza dell'asta 0,5, massa del carrello 1,0, gravità 9,8) funziona ancora se la fisica cambia? Si addestrano due tipi di agente, tre semi ciascuno: (a) solo fisica nominale; (b) fisica randomizzata a ogni episodio, con lunghezza dell'asta fra 0,25 e 1,0 e massa del carrello fra 0,5 e 2,0. Per ciascuno si sceglie il checkpoint migliore sul regime di addestramento (keep_best=True) e lo si valuta in cinque condizioni, fra cui una mai randomizzata: gravità 15.
class Physics(gym.Wrapper):
"""Change the physics of CartPole. `sampler` is called at every reset and returns a dict of parameters."""
def __init__(self, env, sampler):
super().__init__(env); self.sampler = sampler
def reset(self, **kwargs):
u = self.env.unwrapped
p = self.sampler()
u.length, u.masscart, u.gravity = p.get("length", 0.5), p.get("masscart", 1.0), p.get("gravity", 9.8)
u.total_mass = u.masspole + u.masscart # derived quantities must be kept consistent
u.polemass_length = u.masspole * u.length
return self.env.reset(**kwargs)
nominal = lambda: gym.make("CartPole-v1")
def fixed(**params):
return lambda: Physics(gym.make("CartPole-v1"), lambda: params)
def randomised():
rng = np.random.default_rng(123)
return lambda: Physics(gym.make("CartPole-v1"), lambda: dict(length=rng.uniform(0.25, 1.0), masscart=rng.uniform(0.5, 2.0)))
tests = {
"nominal": fixed(),
"short pole (0.25)": fixed(length=0.25),
"long pole (1.0)": fixed(length=1.0),
"heavy cart (3.0)": fixed(masscart=3.0),
"gravity 15 (unseen kind)": fixed(gravity=15.0),
}
agents = {"nominal": nominal, "randomised": randomised()}
def run_regime(regime, seeds=3):
factory = agents[regime]
table = {name: [] for name in tests}
for seed in range(seeds):
q, _, _ = dqn(steps=20_000, seed=seed, lr=5e-4, env_factory=factory, eval_every=5_000,
eval_fn=lambda q: evaluate_greedy(q, 20, env_factory=factory), keep_best=True) # best checkpoint on the TRAINING regime
for name, tf in tests.items():
table[name].append(evaluate_greedy(q, 30, env_factory=tf))
return {name: float(np.mean(v)) for name, v in table.items()}
for regime in ('nominal', 'randomised'):
print(regime, {k: round(v, 1) for k, v in run_regime(regime).items()})
# nominal {'nominal': 203.8, 'short pole (0.25)': 213.1, 'long pole (1.0)': 45.1, 'heavy cart (3.0)': 195.0, 'gravity 15 (unseen kind)': 186.9}
# randomised {'nominal': 336.6, 'short pole (0.25)': 387.9, 'long pole (1.0)': 270.6, 'heavy cart (3.0)': 260.9, 'gravity 15 (unseen kind)': 278.4}
La classe Physics è un wrapper di Gymnasium che a ogni reset chiede a una funzione i nuovi parametri e li scrive nell'ambiente, aggiornando anche le grandezze derivate total_mass e polemass_length: se non lo facessi, la fisica sarebbe incoerente. Attenzione al generatore della randomizzazione: randomised() usa un default_rng(123) condiviso fra tutti gli episodi di quel regime, quindi i parametri estratti dipendono dall'ordine delle chiamate e restano riproducibili solo se rilanci l'intero listato. Ogni cella della tabella è la media di 30 episodi per seme e poi di 3 semi.
Lettura. L'agente addestrato sulla fisica nominale fa 204 sul nominale e 213 con l'asta corta, ma 45 con l'asta lunga (−78%), 195 con il carrello pesante e 187 con la gravità 15. L'agente randomizzato fa di più in tutte e cinque le condizioni: 337 sul nominale, 388 con l'asta corta, 271 con l'asta lunga, 261 con il carrello pesante, 278 con la gravità mai vista. Questo è l'opposto di quanto riportava la versione precedente di questa lezione (40.000 passi, tre semi), dove la randomizzazione peggiorava il nominale senza dare robustezza. I due risultati non si conciliano, e io non ho un esperimento che dica quale sia il più credibile: con tre semi e checkpoint scelti su 20 episodi di valutazione la varianza è grande, e una media di tre addestramenti può essere trascinata da un solo agente fortunato.
Quello che questa misura sostiene con ragionevole sicurezza è più modesto: l'agente nominale è molto più fragile quando l'asta si allunga (45 contro 204 sul nominale), cioè c'è un overfitting al simulatore visibile anche con una rete; e a questo budget la randomizzazione non ha peggiorato l'agente, perché ha migliorato tutte le condizioni misurate. Non ne segue che randomizzare sia sempre vantaggioso: significa che a 20.000 passi, con questi semi, non ho trovato un prezzo da pagare. La difesa che resta valida in entrambe le versioni è valutare l'agente su varianti tenute da parte, mai usate per addestrarlo.
Compromessi e trappole
Alcune scelte non hanno un valore giusto. Un replay buffer grande offre campioni più vari ma più vecchi, riferiti a politiche passate; uno piccolo è più recente ma più correlato. Una rete target copiata di rado dà un bersaglio stabile e un apprendimento lento; copiata spesso è più reattiva e più instabile, e in questa lezione non l'ho fatto variare. Un solo ambiente è veloce ma fragile; molti ambienti danno robustezza in teoria e costano di più. Infine il DQN non è l'unica famiglia: i metodi a gradiente della politica (REINFORCE, actor-critic) imparano direttamente la politica e gestiscono meglio le azioni continue, ma non li trattiamo qui.
Le trappole che questo modulo ti lascia sono sette, e conviene averle come lista di controllo:
- Valutare con l'ultimo checkpoint. I dati sopra mostrano il primo checkpoint spesso migliore dell'ultimo (216 contro 115 per un'intera configurazione).
- Un solo seme. Con tre la varianza è già enorme; con uno, qualsiasi conclusione è un aneddoto. Un risultato opposto a quello pubblicato in precedenza lo mostra.
- Non fissare il seme di
action_space.sample(). Le uscite non sono ripetibili e non te ne accorgi finché non rilanci. - Azzerare il bootstrap su un troncamento. Un episodio interrotto dal limite dei 500 passi non è finito per davvero: salva solo
terminated. - Dimenticare
torch.no_grad()nel bersaglio, o derivare attraversoy: si rompe l'idea di semi-gradiente. - Non normalizzare gli ingressi. CartPole è abbastanza ben scalato per funzionare così; altri ambienti, con componenti di scale molto diverse, no.
- Scambiare l'ε basso in addestramento per una valutazione. La valutazione è un'altra cosa: ε = 0, semi nuovi.
Se devo scegliere le due che pesano di più, sono la prima e la seconda: nell'RL profondo il rumore di valutazione è così alto che un risultato senza più semi e senza una regola per scegliere il checkpoint non vale come misura. Le altre cinque sono errori di codice che producono sintomi lontani dalla causa (un ritorno fermo a 9, una curva che non scende), per questo conviene metterle in una lista di controllo prima di accusare l'algoritmo.
Esercizi con soluzione
- Perché nel bersaglio si usa la rete target e non la rete online? Perché ogni passo cambia i pesi online e con essi il bersaglio: la rete insegue un obiettivo che si sposta. La copia congelata lo tiene fermo fra una copia e l'altra.
- A cosa serve il replay buffer? A rompere la correlazione fra campioni consecutivi, addestrando su gruppi casuali di transizioni di momenti diversi, e a riusare ogni esperienza più volte.
- Nell'ablazione, il ritorno costante 9 che cosa indica? Che la politica greedy sceglie sempre la stessa azione e l'asta cade in 9 passi; nel caso controllato i valori Q erano esplosi a circa 5 · 10⁷.
- Con 2 azioni di valore vero 0 e stime N(0, 1), quanto vale in media il massimo? Circa 0,57, come nella simulazione: positivo, anche se la verità è 0.
Gli esercizi controllano tre idee che vale la pena saper dire a parole prima di aprire il codice: perché servono due accorgimenti e non uno, che cosa vuol dire un valore costante in una curva di apprendimento, e perché un massimo di numeri rumorosi non è il massimo dei numeri veri. Se la quarta risposta ti sorprende, rilancia il listato della simulazione cambiando il numero di azioni e guarda come cresce il bias.
Domande frequenti
Perché non basta addestrare la rete con l'ultima transizione?
Perché le transizioni consecutive sono molto simili e il gradiente calcolato su una sola di esse è un cattivo stimatore. Nell'ablazione, senza replay il 66,7% dei checkpoint ha ritorno ≤ 15 e il massimo osservato è 62.
Il Double DQN è sempre meglio del DQN?
Non l'ho dimostrato qui: con tre semi e 20.000 passi le medie dei ritorni (152 contro 149) non si distinguono e la sovrastima non compare in nessuna delle due. Corregge un bias reale del massimo, misurato nella simulazione, a costo di una riga.
Quanti semi servono per fidarsi di un risultato?
Più di tre. Qui tre semi hanno già dato un risultato sulla randomizzazione opposto a quello di una prova precedente più lunga. Per confrontare due configurazioni servono abbastanza semi da poter dire se la differenza supera la dispersione fra semi.
Che cosa sai fare adesso
Sai costruire un DQN su CartPole con replay buffer, rete target, perdita di Huber e Double DQN opzionale, fissandone tutti i semi perché l'uscita sia ripetibile, e sai usare i quattro interruttori per fare un'ablazione. Hai visto che senza rete target i valori Q esplodono (l'agente sceglie sempre la stessa azione e resta a 9), che senza replay l'apprendimento è quasi nullo, e che con entrambi l'agente impara ma oscilla, con il primo checkpoint spesso migliore dell'ultimo. Hai misurato il bias del massimo in un caso controllato e non l'hai trovato in una rete reale breve, e hai visto un agente addestrato su un'unica fisica perdere il 78% del ritorno con l'asta lunga. Il percorso del modulo si chiude così: il processo di Markov è il linguaggio del problema, Bellman è la legge che il valore deve rispettare, il Q-Learning la risolve senza modello, il DQN la risolve quando gli stati sono troppi. Ogni passo toglie un limite al precedente e ne aggiunge uno: nell'RL il lavoro vero sta nelle misure.
Nella serie, la successiva è Che cos'è un agente AI (e quando non ti serve).
Fonti
- Mnih et al., Playing Atari with Deep Reinforcement Learning (arXiv 1312.5602) - l'idea di campionare a caso da transizioni passate (experience replay) per attenuare dati correlati e distribuzioni non stazionarie; PDF scaricato e letto, consultata il 2026-11-12
- Mnih et al., Human-level control through deep reinforcement learning, Nature 518 (2015) - il DQN con rete target; la pagina elenca la tabella dei dati estesi «The effects of replay and separating the target Q-network» (non ne riporto i valori), consultata il 2026-11-12
- van Hasselt, Guez, Silver, Deep Reinforcement Learning with Double Q-learning (arXiv 1509.06461) - sovrastima del Q-learning e del DQN in alcuni giochi Atari e idea del Double Q-learning; abstract letto, consultata il 2026-11-12
- Sutton e Barto, Reinforcement Learning: An Introduction, seconda edizione, paragrafo 11.3 - triade mortale (approssimazione di funzioni, bootstrapping, addestramento off-policy); PDF scaricato e letto, consultata il 2026-11-12
- Gymnasium, CartPole - terminazione a ±12° e ±2,4, troncamento a 500 passi, ricompensa +1 per passo; pagina scaricata e letta, consultata il 2026-11-12
- Misure di questa lezione: Python 3.12.13, PyTorch 2.12.0 (CPU), NumPy 2.4.6, Gymnasium 1.4.0 (installato in una cartella temporanea con
pip install --target, usandoPYTHONPATH); ogni listato eseguito conCUDA_VISIBLE_DEVICES="", 2026-11-12. Rieseguiti due volte con uscita identica: la tabella a intervalli, la simulazione del massimo e l'intero listato del simulatore diverso; dell'ablazione e della tabella su sovrastima e instabilità sono stati rieseguiti solo due addestramenti (DQN standard seme 0, Double DQN seme 0 con passo 5e-4), anch'essi identici
Commenti
Caricamento commenti...
Accedi per commentare