Salta al contenuto principale

79 - Gymnasium in pratica: Taxi, FrozenLake e l'agente che memorizza il simulatore

Taxi e FrozenLake con Gymnasium: Q-Learning contro l'ottimo esatto, un protocollo di valutazione onesto e l'agente che memorizza il simulatore.

Cosa ne pensi di questo articolo?

79 - Gymnasium in pratica: Taxi, FrozenLake e l'agente che memorizza il simulatore
Machine Learning Engineerfedericocalo.dev

In Breve

Taxi e FrozenLake con Gymnasium: Q-Learning contro l'ottimo esatto, un protocollo di valutazione onesto e l'agente che memorizza il simulatore.

Finora gli ambienti li abbiamo scritti noi: una griglia, un corridoio, una scogliera. Nel lavoro vero si usano ambienti standard, forniti da una libreria, che permettono di confrontare algoritmi diversi sullo stesso problema. La libreria di riferimento è Gymnasium, la continuazione mantenuta di OpenAI Gym. Qui usi il Q-Learning su due suoi ambienti classici, Taxi e FrozenLake, imparando l'API, un protocollo di valutazione che non inganna e un risultato da ricordare ogni volta che addestri un agente in simulazione: un agente può imparare benissimo il simulatore senza imparare il compito. Il fenomeno si misura: un agente perfetto al 100% sul lago di addestramento scende al 5.3% appena il lago diventa scivoloso e al 17.5% su altre mappe.

Il vocabolario di Gymnasium

Gymnasium definisce un'interfaccia comune per gli ambienti di RL e ne contiene molti. I numeri di questo articolo sono ottenuti con la versione 1.4.0, installata in una cartella temporanea e non nell'ambiente di sistema; gli identificatori degli ambienti cambiano fra versioni: con la 1.4.0 Taxi-v3 risulta deprecato e si usa Taxi-v4, quindi controlla sempre il nome nella tua installazione. Lo spazio delle osservazioni e delle azioni (observation_space, action_space) descrive che cosa l'agente vede e che cosa può fare: Discrete(n) è un intero da 0 a n − 1. Un wrapper è un livello che modifica un ambiente senza cambiarne il codice, come il TimeLimit che tronca gli episodi lunghi, e il seed rende riproducibile la casualità dell'ambiente: env.reset(seed=…).

Un episodio finisce in due modi diversi. Con terminated si è raggiunto uno stato terminale del problema (consegnato il passeggero, caduti in un buco); con truncated l'episodio è stato interrotto dall'esterno (limite di tempo) anche se lo stato non era finale. La guida all'uso di Gymnasium commenta infatti truncated come «vero se si raggiunge il limite di tempo». Distinguerli è importante per il bootstrapping, come vedrai nel codice. Il protocollo di valutazione è la procedura con cui si misura una politica: politica fissa (ε = 0), episodi nuovi con seed mai usati in addestramento, molti episodi e una metrica di successo definita prima. Infine l'overfitting al simulatore è la differenza fra le prestazioni nell'ambiente di addestramento e in uno leggermente diverso, il «mondo reale»; in inglese si parla di sim-to-real gap.

L'analogia: lo studente che impara a memoria le risposte

Uno studente prepara l'esame ripassando mille volte lo stesso foglio di domande del prof dell'anno scorso, memorizzando le risposte. Se all'esame le domande sono le stesse, prende 30; se sono appena diverse, crolla. Non ha imparato la materia, ha imparato quel foglio. Un agente addestrato sempre sullo stesso simulatore è nella stessa situazione: la politica può essere perfetta lì e fragile appena qualcosa cambia (un lago scivoloso, un altro labirinto). Con il Q-Learning a tabella è ancora più marcato: lo «stato» è solo un numero di cella, quindi la tabella è la mappa memorizzata.

L'API di Gymnasium in cinque righe

Il listato crea il Taxi, lo resetta con un seme, esegue un'azione casuale e stampa ciò che l'ambiente restituisce. L'azione casuale, env.action_space.sample(), ha il suo seme (env.action_space.seed(0)): lo spazio delle azioni ha un generatore proprio, distinto da quello di reset, e senza seme l'uscita cambierebbe a ogni esecuzione. Con il seme 0 l'azione estratta è la 5 (lascia), illegale perché il passeggero non è nel taxi: la ricompensa è −10 e l'osservazione resta 314.

import numpy as np
import gymnasium as gym

env = gym.make("Taxi-v4")
env.action_space.seed(0)                           # lo spazio delle azioni ha un generatore proprio
obs, info = env.reset(seed=0)
print("observation space:", env.observation_space, "| action space:", env.action_space)
print("first observation:", obs, "| info:", info)
obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
print("after one step ->", dict(obs=obs, reward=reward, terminated=terminated, truncated=truncated))
print("episode time limit:", env.spec.max_episode_steps)
print("decoded state 0..:", list(env.unwrapped.decode(obs)), "(taxi row, taxi col, passenger location, destination)")

# Output (due esecuzioni consecutive, stessa uscita; gymnasium 1.4.0, numpy 2.5.4 su Python 3.14):
# observation space: Discrete(500) | action space: Discrete(6)
# first observation: 314 | info: {'prob': 1.0, 'action_mask': array([1, 1, 0, 0, 0, 0], dtype=int8)}
# after one step -> {'obs': 314, 'reward': -10, 'terminated': False, 'truncated': False}
# episode time limit: 200
# decoded state 0..: [3, 0, 3, 2] (taxi row, taxi col, passenger location, destination)

gym.make("Taxi-v4") crea l'ambiente. L'osservazione è un intero fra 0 e 499, e l'azione un intero fra 0 e 5: sei azioni (sud, nord, est, ovest, raccogli, lascia). I 500 stati sono 25 posizioni del taxi × 5 posizioni del passeggero (quattro fermate più «dentro il taxi») × 4 destinazioni; il metodo decode li scompone. env.reset(seed=0) restituisce la prima osservazione e un dizionario info (qui con una maschera delle azioni possibili). env.step(a) restituisce cinque valori: osservazione, ricompensa, terminated, truncated, info. Il limite di 200 passi è un wrapper TimeLimit. Le ricompense del Taxi, secondo la documentazione: −1 per ogni passo, +20 per una consegna riuscita, −10 per raccolta o consegna illegali.

Q-Learning con Gymnasium, e un dettaglio che conta: terminated contro truncated

Riscriviamo il Q-Learning dell'articolo precedente sull'API di Gymnasium, con un tasso di esplorazione ε che decresce linearmente da 1 a 0.05 (all'inizio si esplora molto, poi si sfrutta di più), e una funzione evaluate che applica il protocollo corretto. In coda c'è un riferimento: la politica casuale.

def q_learning_gym(env_id, episodes, alpha=0.1, gamma=0.99, eps_start=1.0, eps_end=0.05, eps_decay_episodes=None, seed=0, **make_kwargs):
    """Tabular Q-learning on any Gymnasium environment with Discrete observations and actions."""
    env = gym.make(env_id, **make_kwargs)
    n_s, n_a = env.observation_space.n, env.action_space.n
    rng = np.random.default_rng(seed)
    Q = np.zeros((n_s, n_a))
    eps_decay_episodes = eps_decay_episodes or episodes // 2
    returns = []
    for ep in range(episodes):
        epsilon = max(eps_end, eps_start - (eps_start - eps_end) * ep / eps_decay_episodes)    # linear decay
        s, _ = env.reset(seed=int(rng.integers(1 << 30)))
        total, done = 0.0, False
        while not done:
            a = int(rng.integers(n_a)) if rng.random() < epsilon else int(rng.choice(np.flatnonzero(Q[s] == Q[s].max())))
            s2, r, terminated, truncated, _ = env.step(a)
            # IMPORTANT: only `terminated` cuts the bootstrap. A time-limit `truncated` does not mean the state is worthless.
            target = r + (0.0 if terminated else gamma * Q[s2].max())
            Q[s, a] += alpha * (target - Q[s, a])
            s, total, done = s2, total + r, (terminated or truncated)
        returns.append(total)
    return Q, np.array(returns)

def evaluate(env_id, Q, episodes=200, seed=1000, **make_kwargs):
    """Greedy policy (epsilon = 0) on FRESH episodes: mean return and fraction of episodes that ended with a positive reward."""
    env = gym.make(env_id, **make_kwargs)
    totals, successes = [], 0
    for i in range(episodes):
        s, _ = env.reset(seed=seed + i)
        total, done = 0.0, False
        while not done:
            s, r, terminated, truncated, _ = env.step(int(np.argmax(Q[s])))
            total += r; done = terminated or truncated
        totals.append(total); successes += int(terminated and r > 0)     # `terminated` alone also covers falling into a hole!
    return float(np.mean(totals)), successes / episodes

# the random policy, as a reference
rng = np.random.default_rng(0)
env = gym.make("Taxi-v4")
env.action_space.seed(0)                           # make the random baseline reproducible
rand = []
for i in range(200):
    env.reset(seed=500 + i); total, done = 0.0, False
    while not done:
        _, r, terminated, truncated, _ = env.step(env.action_space.sample()); total += r; done = terminated or truncated
    rand.append(total)
print(f"random policy on Taxi: mean return {np.mean(rand):7.1f}")

for episodes in (500, 5000, 20000):
    Q, train_returns = q_learning_gym("Taxi-v4", episodes)
    mean_ret, success = evaluate("Taxi-v4", Q)
    print(f"{episodes:6d} training episodes: greedy evaluation mean return {mean_ret:6.2f}, deliveries completed {100 * success:5.1f}%")

# Output (due esecuzioni consecutive, stessa uscita; gymnasium 1.4.0, numpy 2.5.4 su Python 3.14):
# random policy on Taxi: mean return  -764.0
#    500 training episodes: greedy evaluation mean return -185.13, deliveries completed   7.0%
#   5000 training episodes: greedy evaluation mean return   7.96, deliveries completed 100.0%
#  20000 training episodes: greedy evaluation mean return   7.96, deliveries completed 100.0%

In q_learning_gym il ciclo è quello dell'articolo precedente; le novità sono tre. Il decadimento di ε: epsilon = max(eps_end, eps_start − (eps_start − eps_end)·ep/eps_decay_episodes), lineare. Poi il bootstrap che si interrompe solo con terminated: target = r + (0 if terminated else γ·max Q[s2]). Se l'episodio è stato troncato dal limite di tempo, lo stato successivo non è «senza futuro»: il problema avrebbe continuato, e azzerare il bootstrap in quel caso insegnerebbe all'agente che gli stati vicini al limite di tempo valgono meno di quanto valgano. È un bug comune e silenzioso. Il ciclo però si ferma con terminated or truncated. Infine ogni nuovo episodio parte con un seed estratto dal generatore, così gli episodi di addestramento sono variati ma riproducibili.

evaluate è il protocollo: ε = 0 (argmax), episodi nuovi con seed a partire da 1000 (mai usati in addestramento), 200 episodi. Definisce il successo come «episodio terminato con ricompensa positiva» (terminated and r > 0): attenzione a questa riga, perché in FrozenLake terminated è vero anche quando si cade in un buco, e contare solo terminated darebbe un successo del 100% a un agente che cade sempre. Sul Taxi la politica casuale ottiene in media −764 (episodi che durano tutti i 200 passi, con molte azioni illegali da −10). Dopo 500 episodi di addestramento l'agente è ancora scarso (−185, consegna il 7% delle volte). Dopo 5000 raggiunge +7.96 con il 100% di consegne, e 20000 non cambiano nulla: è convergito. Per sapere se 7.96 è il massimo, calcoliamo la politica ottima esatta con la value iteration sul modello che Gymnasium espone per gli ambienti di tipo «toy-text» (env.unwrapped.P, la stessa tabella dell'articolo sull'MDP) e la valutiamo con lo stesso protocollo.

def optimal_policy_from_model(env_id, gamma=1.0, **make_kwargs):
    """Gymnasium's toy-text environments expose their model in env.unwrapped.P, so we can compute the exact optimum for reference."""
    P = gym.make(env_id, **make_kwargs).unwrapped.P
    n_s, n_a = len(P), len(P[0])
    V = np.zeros(n_s)
    for _ in range(2000):
        for s in range(n_s):
            V[s] = max(sum(p * (r + gamma * V[s2] * (not t)) for p, s2, r, t in P[s][a]) for a in range(n_a))
    return np.array([[sum(p * (r + gamma * V[s2] * (not t)) for p, s2, r, t in P[s][a]) for a in range(n_a)] for s in range(n_s)])

# Is 7.96 the best possible on Taxi?  Compute the exact optimum from the model Gymnasium exposes, and evaluate it the same way.
Q_taxi_opt = optimal_policy_from_model("Taxi-v4", gamma=0.99)
print("exact optimal policy on Taxi: mean return %.2f, deliveries %.0f%%" % (evaluate("Taxi-v4", Q_taxi_opt)[0], 100 * evaluate("Taxi-v4", Q_taxi_opt)[1]))

# Output (due esecuzioni consecutive, stessa uscita; gymnasium 1.4.0, numpy 2.5.4 su Python 3.14):
# exact optimal policy on Taxi: mean return 7.96, deliveries 100%

La funzione optimal_policy_from_model esegue la value iteration (2000 sweep, più che sufficienti per questi ambienti) usando env.unwrapped.P e restituisce la tabella q*; la politica avida rispetto a essa, valutata con evaluate, ottiene 7.96. Il valore ottimo è dunque 7.96: l'agente ha imparato la politica ottima, senza mai vedere il modello. È un buon test di salute: quando è possibile, confrontare con un riferimento esatto dice se un risultato è «buono» o solo «migliore di prima».

FrozenLake: quando anche la politica ottima non vince sempre

FrozenLake è un lago ghiacciato 4×4: si parte da S, si deve arrivare a G senza cadere nei buchi H. Con is_slippery=True (il default) il ghiaccio è scivoloso: secondo la documentazione la mossa riesce con probabilità 1/3 e devia in ciascuna delle due direzioni perpendicolari con probabilità 1/3. La ricompensa è 1 solo all'arrivo al traguardo, 0 altrimenti: ricompensa sparsa e ritardata, il caso dell'articolo su ricompense ritardate e reward hacking. Dato che il mondo è stocastico, anche la politica migliore possibile non arriva sempre al traguardo: bisogna sapere a quanto arriva, altrimenti non si può giudicare il Q-Learning.

# FrozenLake 4x4: walk from S to G over a frozen lake without falling into holes; on a slippery lake the move often slips sideways.

Q_opt = optimal_policy_from_model("FrozenLake-v1", map_name="4x4", is_slippery=True)
ok = evaluate("FrozenLake-v1", Q_opt, episodes=5000, map_name="4x4", is_slippery=True)
print(f"exact optimal policy (computed with the model): success rate {100 * ok[1]:.1f}%")

for episodes in (1000, 20000, 100000):
    Q, _ = q_learning_gym("FrozenLake-v1", episodes, alpha=0.05, gamma=0.99, map_name="4x4", is_slippery=True)
    m, success = evaluate("FrozenLake-v1", Q, episodes=5000, map_name="4x4", is_slippery=True)
    print(f"Q-learning, {episodes:6d} episodes: success rate {100 * success:5.1f}%")

# Output (due esecuzioni consecutive, stessa uscita; gymnasium 1.4.0, numpy 2.5.4 su Python 3.14):
# exact optimal policy (computed with the model): success rate 72.9%
# Q-learning,   1000 episodes: success rate   2.9%
# Q-learning,  20000 episodes: success rate  72.9%
# Q-learning, 100000 episodes: success rate  72.9%

La politica avida ottima ottiene il 72.9% di successi su 5000 episodi: è il massimo per questo lago scivoloso. Il Q-Learning, con α = 0.05 e decadimento di ε, dopo 1000 episodi è al 2.9% (non ha quasi mai visto il traguardo), dopo 20000 arriva al 72.9%, esattamente l'ottimo, e 100000 non cambiano. Ne discendono due lezioni. Il confronto con 72.9% e non con 100% è ciò che dà senso al numero. E il passaggio da 2.9% a 72.9% mostra il problema dell'esplorazione con ricompensa sparsa: nei primi episodi l'agente non trova mai il premio e non ha da che cosa imparare.

L'agente che memorizza il simulatore

Ora la parte che conta. Faremo due esperimenti sulla stessa idea: addestrare in un ambiente e valutare in uno leggermente diverso.

from gymnasium.envs.toy_text.frozen_lake import generate_random_map

# (1) train in a DETERMINISTIC simulator, deploy in a SLIPPERY one
Q_det, _ = q_learning_gym("FrozenLake-v1", 20000, alpha=0.5, gamma=0.99, map_name="4x4", is_slippery=False)
print("trained on the non-slippery lake:")
print("   success on the non-slippery lake:", f"{100 * evaluate('FrozenLake-v1', Q_det, 2000, map_name='4x4', is_slippery=False)[1]:5.1f}%")
print("   success on the SLIPPERY lake    :", f"{100 * evaluate('FrozenLake-v1', Q_det, 2000, map_name='4x4', is_slippery=True)[1]:5.1f}%   (the optimum there is 72.9%)")

# (2) train on ONE map, deploy on other maps of the same size (non-slippery, so that a perfect policy would score 100%)
train_map = generate_random_map(size=6, p=0.85, seed=0)
Q_map, _ = q_learning_gym("FrozenLake-v1", 20000, alpha=0.5, gamma=0.99, desc=train_map, is_slippery=False)
print("trained on one 6x6 map:")
print("   success on the map it was trained on :", f"{100 * evaluate('FrozenLake-v1', Q_map, 200, desc=train_map, is_slippery=False)[1]:5.1f}%")
others = []
for k in range(1, 41):
    other = generate_random_map(size=6, p=0.85, seed=k)
    others.append(evaluate("FrozenLake-v1", Q_map, 1, desc=other, is_slippery=False)[1])
print(f"   success on 40 OTHER random 6x6 maps   : {100 * np.mean(others):5.1f}%")

# Output (due esecuzioni consecutive, stessa uscita; gymnasium 1.4.0, numpy 2.5.4 su Python 3.14):
# trained on the non-slippery lake:
#    success on the non-slippery lake: 100.0%
#    success on the SLIPPERY lake    :   5.3%   (the optimum there is 72.9%)
# trained on one 6x6 map:
#    success on the map it was trained on : 100.0%
#    success on 40 OTHER random 6x6 maps   :  17.5%
Un agente tabulare memorizza la mappa: la stessa sequenza di celle funziona sulla mappa di addestramento e cade sui buchi di un'altra mappa; misurato 100 per cento contro 17.5 per cento su altre mappe e 100 contro 5.3 per cento su un lago scivoloso Un agente tabulare memorizza la mappa: lo "stato" è solo un numero di cella, quindi non può generalizzare mappa di addestramento (seme 0) S G un'altra mappa (seme 1) S G percorso memorizzato: funziona su questa mappa stessi numeri di cella, buchi diversi: cade (il percorso è disegnato a scopo illustrativo: non è il tragitto esatto imparato dall'agente) misurato: 100% sulla mappa di addestramento, 17.5% su 40 altre mappe casuali; 100% su un lago non scivoloso, 5.3% sul lago scivoloso
Un agente tabulare memorizza la mappa: la stessa sequenza di celle funziona sulla mappa di addestramento e cade sui buchi di un'altra mappa; misurato 100 per cento contro 17.5 per cento su altre mappe e 100 contro 5.3 per cento su un lago scivoloso

Nell'esperimento 1 si addestra in un simulatore deterministico (is_slippery=False) e poi si valuta sia lì sia nel lago scivoloso. Nel mondo di addestramento il successo è 100%; nel lago scivoloso 5.3%, contro il 72.9% che un agente addestrato sul lago scivoloso sa fare. L'agente ha imparato una sequenza di mosse precise, che funziona solo se ogni mossa va esattamente dove deve. Nell'esperimento 2 si addestra su una mappa 6×6 generata casualmente (generate_random_map(size=6, p=0.85, seed=0)) e si valuta su 40 altre mappe della stessa dimensione (seed da 1 a 40, ciascuna una volta, in modalità non scivolosa, così che una politica perfetta otterrebbe 100%). Sulla mappa di addestramento il successo è 100%; sulle altre 17.5%.

Perché? Per l'agente tabulare «stato» significa «numero di cella»: non sa nulla della mappa, ha solo imparato «dalla cella 7 vai a destra, dalla 8 giù…». Su un'altra mappa la cella 7 ha buchi diversi intorno, e non vedendo la mappa l'agente non può adattarsi: la disposizione dei buchi non esiste nel suo spazio di stati. Il 17.5% non è un caso: su certe mappe il percorso memorizzato è per caso libero da buchi. Non ho misurato quanto otterrebbe una politica casuale su quelle mappe, quindi non so dire quanto sia sopra il caso. Questo è overfitting al simulatore, e non è un difetto del Q-Learning: è una proprietà dell'addestramento sempre sullo stesso ambiente.

I rimedi che si usano in pratica hanno ciascuno il proprio costo. La domain randomization addestra su molte varianti dell'ambiente (mappe diverse, attriti diversi, rumore) perché la politica non possa memorizzarne una, ma richiede un agente capace di vedere la differenza. Per questo si mette nell'osservazione ciò che cambia: se la mappa cambia, la mappa deve far parte dello stato, cosa impossibile con una tabella (gli stati esplodono) e fattibile con una rete neurale che riceve la mappa in ingresso, la strada della Deep Q-Learning. Si tengono poi da parte mappe, seed e parametri fisici per la valutazione, come si tiene da parte il test set: un agente non è «finito» finché non è stato provato fuori dal suo simulatore. E se la politica è sensibile a perturbazioni piccole, non è pronta per il mondo reale, qualunque margine di sicurezza si stimi.

Un protocollo di valutazione che non inganna

  1. Politica congelata (ε = 0, nessun aggiornamento durante la valutazione).
  2. Episodi nuovi: seed e (se possibile) varianti dell'ambiente mai usati in addestramento.
  3. Molti episodi (centinaia o migliaia): in ambienti stocastici pochi episodi danno numeri rumorosi.
  4. Più addestramenti indipendenti (seed diversi dell'agente), con media e deviazione standard: un solo addestramento può essere fortunato.
  5. Una metrica di successo definita prima e distinta dalla ricompensa di addestramento (come «consegna completata», non «ricompensa totale»).
  6. Un riferimento: la politica ottima esatta, quando si può calcolare; altrimenti una politica semplice e una casuale.

I sei punti si leggono come un'unica difesa contro l'autoinganno: i primi tre evitano di misurare la memoria invece della capacità, il quarto evita di scambiare un addestramento fortunato per un metodo, gli ultimi due danno un metro per giudicare il numero. Nei nostri esperimenti i punti 1, 2, 3 e 6 sono rispettati; il punto 4 no, perché ogni addestramento è stato fatto con un solo seme dell'agente. Questo è un limite dichiarato dei numeri riportati: sul Taxi e sul FrozenLake scivoloso i risultati coincidono con l'ottimo, e questo rende improbabile che siano frutto di fortuna, ma non lo dimostra.

I compromessi e le trappole

La tabella è precisa ma fragile: nei problemi piccoli e fissi dà l'ottimo (7.96 sul Taxi, 72.9% su FrozenLake) e non generalizza a ciò che non ha visto. Un solo ambiente di addestramento è semplice e veloce, molti rendono la politica robusta ma l'apprendimento più difficile. Un decadimento di ε troppo veloce ferma l'agente su una politica sub-ottima, troppo lento spreca episodi.

Le trappole discendono da qui. Contare terminated come successo fa risultare «perfetto» un agente che cade sempre nei buchi. Azzerare il bootstrap su truncated insegna valori sbagliati vicino al limite di tempo. Valutare con ε > 0 o con gli stessi seed dell'addestramento misura la memoria, non la capacità. Confrontare con 100% quando il massimo è 72.9% fa sembrare scarso un agente ottimo. Pensare che una politica perfetta in simulazione sia pronta per il mondo reale è l'errore dei 100% che diventano 5.3% con un po' di scivolosità. E dimenticare che gli id degli ambienti cambiano fra versioni (Taxi-v3 contro Taxi-v4) rende i risultati non riproducibili: registra la versione della libreria insieme ai numeri.

Un esempio svolto a mano: la codifica del Taxi e quanti episodi servono per fidarsi di una percentuale

La codifica dello stato. Nel Taxi lo stato è un solo intero che impacchetta quattro informazioni: stato = ((riga_taxi · 5 + colonna_taxi) · 5 + posizione_passeggero) · 4 + destinazione. L'osservazione 314 che abbiamo visto si decodifica così: 314 mod 4 = 2 (destinazione 2), 314 div 4 = 78; 78 mod 5 = 3 (passeggero in posizione 3), 78 div 5 = 15; 15 mod 5 = 0 (colonna 0), 15 div 5 = 3 (riga 3). Cioè [3, 0, 3, 2], come stampato da decode. Il massimo stato è ((4·5 + 4)·5 + 4)·4 + 3 = 499: da qui i 500 stati.

Quanti episodi di valutazione servono? Una percentuale di successo misurata su n episodi è una stima, con errore standard √(p(1 − p)/n). Per il FrozenLake ottimo (p = 0.729): con n = 200 l'errore standard è √(0.729·0.271/200) = 0.031, cioè ±6.2 punti percentuali al 95% (due errori standard); con n = 5000 è 0.0063, cioè ±1.2 punti. Ecco perché abbiamo valutato il FrozenLake su 5000 episodi e non su 200: con 200, la differenza fra 70% e 75% sarebbe dentro il rumore. Per il Taxi, dove la politica è deterministica e il successo è 100%, bastano pochi episodi.

E il 17.5% delle «altre mappe»? Sono 40 mappe valutate con un solo episodio ciascuna (non scivoloso, quindi deterministico): 17.5% = 7 successi su 40, con errore standard √(0.175·0.825/40) = 0.060, cioè ±12 punti. Il risultato «molto sotto il 100%» è solido; la cifra esatta è approssimata.

Esercizi con soluzione

  1. Decodifica lo stato 0 e lo stato 499 del Taxi.
    Soluzione: stato 0: [0, 0, 0, 0]. Stato 499: [4, 4, 4, 3] (riga 4, colonna 4, passeggero 4, destinazione 3).
  2. Un agente ha successo 90% su 1000 episodi di valutazione. Qual è l'errore standard?
    Soluzione: √(0.9·0.1/1000) = 0.0095, cioè circa ±1.9 punti al 95%.
  3. Perché nel FrozenLake non si conta terminated come successo?
    Soluzione: perché terminated è vero anche cadendo in un buco: va contato come successo solo se la ricompensa finale è positiva.
  4. Il bootstrap va azzerato per truncated?
    Soluzione: no: truncated indica un'interruzione esterna (tempo), lo stato non è privo di futuro. Si azzera solo per terminated.
  5. L'agente ottiene il 72.9% su FrozenLake scivoloso: è un buon risultato?
    Soluzione: sì, 72.9% è il massimo ottenibile (politica ottima calcolata dal modello); il giudizio nasce dal confronto con l'ottimo, non con 100%.

I primi due esercizi sono calcoli da fare con carta e matita, e vanno ricontrollati con una riga di Python (le soluzioni sono state ricalcolate così). Gli altri tre sono i punti in cui un esperimento di RL si falsifica senza che nessun errore compaia: se li sai spiegare, sai già leggere criticamente i risultati degli articoli successivi.

Domande frequenti

Perché env.reset(seed=…) solo alla prima chiamata?

Il seed inizializza il generatore dell'ambiente; chiamarlo a ogni episodio con lo stesso numero produrrebbe sempre lo stesso episodio. Si passa a reset un seed diverso per episodio (come in evaluate) oppure lo si imposta una sola volta.

Che cos'è il campo info?

Un dizionario con informazioni extra non usate dall'agente (nel Taxi, la probabilità della transizione e una maschera delle azioni valide). Non va usato per decidere in valutazione se non fa parte dello stato accessibile all'agente.

Posso usare Gymnasium per un mio problema?

Sì: si scrive una classe che eredita da gym.Env e implementa reset e step con spazi di osservazione e azione definiti, ed è ciò che permette di far lavorare gli stessi algoritmi su problemi diversi. Non ho provato qui questo percorso.

Perché la tabella Q del Taxi ha successo con 5000 episodi e non con 500?

Perché ci sono 500 stati × 6 azioni = 3000 celle da imparare e, con ε-greedy, ogni cella va visitata più volte. Con 500 episodi molte non sono mai state toccate.

È normale che il ritorno sia 7.96 e non 20?

Sì: la consegna dà +20 ma ogni passo precedente costa −1, quindi un ritorno medio di 7.96 corrisponde a circa 13 passi per episodio (21 − 7.96 ≈ 13, perché l'ultimo passo vale +20 al posto di −1).

Che cosa sai fare adesso

Sai usare l'API di Gymnasium (reset e step con cinque valori restituiti), distinguere terminated da truncated e azzerare il bootstrap solo per il primo. Hai visto il Q-Learning a tabella trovare la politica ottima in entrambi gli ambienti: Taxi +7.96 (contro −764 della politica casuale) e FrozenLake 72.9%, pari al massimo calcolato dal modello. E hai visto lo stesso agente, perfetto al 100% nel suo simulatore, scendere al 5.3% su un lago scivoloso e al 17.5% su altre mappe: ha memorizzato l'ambiente, non il compito. Per questo valuti con politica congelata, episodi nuovi e varianti tenute da parte, e confronti con un riferimento. La tabella non generalizza e non scala: l'articolo successivo sostituisce la tabella con una rete neurale (Deep Q-Learning) e incontra i problemi nuovi che questo comporta.

Fonti

  • Gymnasium, Taxi - spazio delle osservazioni Discrete(500), delle azioni Discrete(6), identificatore Taxi-v4 e ricompense (−1 a passo, +20 consegna, −10 azioni illegali); pagina scaricata e letta, consultata il 2026-11-12
  • Gymnasium, Frozen Lake - parametro is_slippery e probabilità 1/3 per ciascuna direzione; pagina scaricata e letta, consultata il 2026-11-12
  • Gymnasium, Basic Usage - ciclo agente-ambiente, step con terminated e truncated; pagina scaricata e letta, consultata il 2026-11-12
  • PyPI, gymnasium 1.4.0 - versione usata per eseguire i listati (installata con pip install --target in una cartella temporanea), consultata il 2026-11-12

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti