Concepts RL de base (policy, reward, discount, on-policy vs off-policy)
Duree estimee : 45-50 minutes
Pourquoi SAC ?
Dans les notebooks précédents, nous avons couvert quatre algorithmes :
Algorithme
Paradigme
Actions
Limite principale
DQN
Off-policy, value-based
Discretes uniquement
Ne gere pas les actions continues
REINFORCE
On-policy, policy-based
Discretes et continues
Variance elevee, lent
A2C
On-policy, actor-critic
Discretes et continues
Sample-inefficace (1 passage par donnee)
PPO
On-policy, actor-critic
Discretes et continues
Clipping limite l’exploration
SAC (Soft Actor-Critic, Haarnoja et al. 2018) combine le meilleur de ces mondes :
Off-policy : reutilise les expériences via un replay buffer (comme DQN)
Actions continues : politique gaussienne avec squashing (contrairement a DQN)
Entropie maximale : l’agent maximise a la fois la recompense ET l’entropie de sa politique, ce qui encourage une exploration naturelle et robuste
Twin Q-networks : deux critiques prennent le minimum de leurs estimations, reduisant la surestimation de Q
SAC est l’algorithme de reference pour les environnements a actions continues. Il est sample-efficient, robuste aux hyperparametres, et converge souvent plus vite que PPO sur ces tâches.
1. Setup et imports
import torchimport torch.nn as nnimport torch.nn.functional as Fimport torch.optim as optimfrom torch.distributions import Normalimport gymnasium as gymimport numpy as npimport matplotlib.pyplot as pltfrom collections import dequeimport os# Reproductibilitetorch.manual_seed(42)np.random.seed(42)print(f"Imports OK : PyTorch {torch.__version__}, Gymnasium {gym.__version__}")# Dimensions de l'environnement Pendulum-v1env = gym.make("Pendulum-v1")state_dim = env.observation_space.shape[0]action_dim = env.action_space.shape[0]max_action =float(env.action_space.high[0])print(f"Environnement : Pendulum-v1 | State dim : {state_dim} | Action dim : {action_dim} | Max action : {max_action}")env.close()
Imports OK : PyTorch 2.13.0+cpu, Gymnasium 1.2.3
Environnement : Pendulum-v1 | State dim : 3 | Action dim : 1 | Max action : 2.0
Nous travaillons sur Pendulum-v1, un environnement de contrôle classique avec un espace d’actions continu dans \([-2, 2]\). L’observation est un vecteur de dimension 3 (cos et sin de l’angle, vitesse angulaire). C’est un benchmark standard pour les algorithmes a actions continues.
2. Le cadre du RL a entropie maximale
L’innovation cle de SAC est de maximiser simultanement la recompense et l’entropie de la politique. L’objectif devient :
ou \(H(\pi) = -\int \pi(a \mid s) \log \pi(a \mid s) \, da\) est l’entropie de la politique et \(\alpha > 0\) est le coefficient de temperature.
Pourquoi maximiser l’entropie ? - Exploration : une politique a haute entropie explore plus diversement les actions possibles - Robustesse : l’agent ne s’engage pas prematurement sur une stratégie sub-optimale - Multi-modalite : si plusieurs stratégies sont aussi bonnes, l’agent les preserve toutes
Le paramètre \(\alpha\) contrôle le compromis exploitation/exploration. SAC propose un reglage automatique de \(\alpha\) (auto-tuning) en traitant \(\alpha\) comme un paramètre apprenable.
2.1 Visualisation : entropie de différentes distributions
def visualize_entropy():"""Compare l'entropie de distributions deterministes vs stochastiques.""" fig, axes = plt.subplots(1, 3, figsize=(14, 4)) x = np.linspace(-4, 4, 200)# Deterministe (delta de Dirac approxime par sigma tres petit) sigma_det =0.1 y_det = np.exp(-0.5* (x / sigma_det)**2) / (sigma_det * np.sqrt(2* np.pi)) entropy_det =0.5* np.log(2* np.pi * np.e * sigma_det**2) axes[0].plot(x, y_det, 'r-', linewidth=2) axes[0].set_title(f'Deterministe\nsigma={sigma_det}, H = {entropy_det:.2f}') axes[0].set_ylim(0, 5) axes[0].fill_between(x, y_det, alpha=0.3, color='red')# Stochastique (sigma moyen) sigma_mid =1.0 y_mid = np.exp(-0.5* (x / sigma_mid)**2) / (sigma_mid * np.sqrt(2* np.pi)) entropy_mid =0.5* np.log(2* np.pi * np.e * sigma_mid**2) axes[1].plot(x, y_mid, 'b-', linewidth=2) axes[1].set_title(f'Stochastique\nsigma={sigma_mid}, H = {entropy_mid:.2f}') axes[1].set_ylim(0, 5) axes[1].fill_between(x, y_mid, alpha=0.3, color='blue')# Tres exploratoire (sigma grand) sigma_high =2.0 y_high = np.exp(-0.5* (x / sigma_high)**2) / (sigma_high * np.sqrt(2* np.pi)) entropy_high =0.5* np.log(2* np.pi * np.e * sigma_high**2) axes[2].plot(x, y_high, 'g-', linewidth=2) axes[2].set_title(f'Haute entropie\nsigma={sigma_high}, H = {entropy_high:.2f}') axes[2].set_ylim(0, 5) axes[2].fill_between(x, y_high, alpha=0.3, color='green')for ax in axes: ax.set_xlabel('Action') ax.set_ylabel('Densite') ax.grid(True, alpha=0.3) plt.suptitle('Entropie et exploration : plus sigma est grand, plus l\'agent explore', fontsize=13, fontweight='bold') plt.tight_layout() plt.show()visualize_entropy()print("Visualisation generee : l'entropie croit avec sigma (ecart-type de la politique)")
Visualisation generee : l'entropie croit avec sigma (ecart-type de la politique)
Interpretation : entropie et exploration
Sortie obtenue : trois gaussiennes avec des ecarts-types croissants.
Distribution
sigma
Entropie
Comportement agent
Déterministe
0.1
~-0.88 (faible)
Exploite une seule action
Stochastique
1.0
~1.42
Equilibre exploration/exploitation
Haute entropie
2.0
~2.11
Explore largement l’espace
Point cle : SAC maximise l’entropie, donc il pousse naturellement la politique vers la distribution de droite tout en maximisant la recompense. Cela produit un agent plus robuste.
3. Composants de SAC
SAC comporte quatre composants principaux :
Composant
Rôle
Analogie
GaussianPolicy (Actor)
Politique \(\pi_\phi(a \mid s)\) gaussienne avec squashing tanh
Comme l’actor de A2C, mais pour actions continues
TwinQNetwork (Critic)
Deux Q-networks \(Q_{\theta_1}, Q_{\theta_2}\) ; on prend le minimum
Comme DQN, mais deux reseaux reduisent la surestimation
Target networks
Copies lentes des Q-networks pour stabiliser l’apprentissage
Identique au target network de DQN
Temperature alpha
Paramètre apprenable qui contrôle le poids de l’entropie
Remplace le epsilon de epsilon-greedy
3.1 Replay Buffer
SAC est off-policy : il stocke les transitions \((s, a, r, s', done)\) dans un buffer et echantillonne des mini-lots pour l’apprentissage. Cela permet de reutiliser chaque expérience plusieurs fois.
ReplayBuffer defini : capacity=100, state_dim=3, action_dim=1
Test push OK : size=1, ptr=1
3.2 Politique gaussienne (Actor)
L’actor de SAC produit une distribution gaussienne\(\mathcal{N}(\mu(s), \sigma^2(s))\) sur les actions. Pour garantir que les actions restent dans \([-1, 1]\), on applique un squashing tanh avec la correction de log-probabilite correspondante.
Le reparameterization trick (rsample) est essentiel : il permet aux gradients de circuler a travers l’echantillonnage stochastique, ce qui rend possible l’optimisation de la politique par descente de gradient.
3.3 Twin Q-Networks (Critic)
SAC utilise deux Q-networks et prend le minimum de leurs estimations pour reduire la surestimation de la valeur Q. C’est le même principe que TD3 (Twin Delayed DDPG). Chaque Q-network est un MLP simple.
class TwinQNetwork(nn.Module):"""Double Q-network pour SAC (reduit la surestimation)."""def__init__(self, state_dim, action_dim, hidden_dim=64):super().__init__()# Q1self.q1 = nn.Sequential( nn.Linear(state_dim + action_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) )# Q2 (architecture identique, poids differents)self.q2 = nn.Sequential( nn.Linear(state_dim + action_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) )def forward(self, state, action):"""Retourne Q1(s,a) et Q2(s,a).""" sa = torch.cat([state, action], dim=-1) q1 =self.q1(sa) q2 =self.q2(sa)return q1, q2# Verificationtwin_q = TwinQNetwork(state_dim, action_dim, hidden_dim=64)test_action = torch.randn(1, action_dim)q1, q2 = twin_q(test_state, test_action)n_params_q =sum(p.numel() for p in twin_q.parameters())print(f"TwinQNetwork defini : input=({state_dim}+{action_dim}), output=1 x 2")print(f"Q1 = {q1.item():.4f}, Q2 = {q2.item():.4f}, min(Q1,Q2) = {min(q1.item(), q2.item()):.4f}")print(f"Nombre de parametres : {n_params_q}")
TwinQNetwork defini : input=(3+1), output=1 x 2
Q1 = 0.0885, Q2 = -0.0799, min(Q1,Q2) = -0.0799
Nombre de parametres : 9090
L’entree du Q-network est la concatenation \([s, a]\) de l’etat et de l’action. Les deux reseaux Q1 et Q2 ont la même architecture mais des poids independants. Lors de l’apprentissage, on utilise \(\min(Q_1, Q_2)\) comme cible pour la mise a jour du critic.
4. Algorithme SAC
L’algorithme SAC effectue trois mises a jour a chaque pas de temps :
4.1 Mise a jour du Critic
La cible Q est calculee avec les target networks et l’entropie :
ou \(\tilde{a}' \sim \pi(\cdot|s')\) est echantillonne depuis la politique courante. On minimise ensuite \(\frac{1}{2}(Q_\theta(s,a) - y)^2\) pour les deux Q-networks.
avec \(\bar{H} = -\dim(\mathcal{A})\) comme heuristique standard (entropie cible = dimension de l’espace d’actions avec un signe negatif).
class SACAgent:"""Agent Soft Actor-Critic avec temperature automatique. twin_q=False coupe l'ablation single-Q (section 5) : cible et actor ne lisent plus que Q1, seule la perte de Q1 est minimisee. """def__init__(self, state_dim, action_dim, hidden_dim=64, lr=3e-4, gamma=0.99, tau=0.005, alpha_lr=3e-4, target_entropy=None, twin_q=True):self.gamma = gammaself.tau = tauself.action_dim = action_dimself.twin_q = twin_q# Reseauxself.policy = GaussianPolicy(state_dim, action_dim, hidden_dim)self.critic = TwinQNetwork(state_dim, action_dim, hidden_dim)self.critic_target = TwinQNetwork(state_dim, action_dim, hidden_dim)# Copier les poids du critic vers la cibleself.critic_target.load_state_dict(self.critic.state_dict())# Optimizersself.policy_optimizer = optim.Adam(self.policy.parameters(), lr=lr)self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=lr)# Temperature automatiqueif target_entropy isNone: target_entropy =-action_dim # heuristique standardself.target_entropy = target_entropyself.log_alpha = torch.zeros(1, requires_grad=True)self.alpha_optimizer = optim.Adam([self.log_alpha], lr=alpha_lr)@propertydef alpha(self):returnself.log_alpha.exp()def select_action(self, state, deterministic=False):"""Selectionne une action pour l'environnement.""" state_t = torch.FloatTensor(state).unsqueeze(0)with torch.no_grad():if deterministic: x =self.policy.net(state_t) mean =self.policy.mean_linear(x) action = torch.tanh(mean)else: action, _, _ =self.policy(state_t)return action.cpu().numpy().flatten() * max_actiondef update(self, batch):"""Effectue une mise a jour SAC sur un mini-lot.""" states, actions, rewards, next_states, dones = batch# --- Mise a jour du Critic ---with torch.no_grad():# Echantillonner a' depuis la politique next_actions, next_log_probs, _ =self.policy(next_states)# Q-cible = min(Q1_target, Q2_target) -- ou Q1 seul en ablation q1_target, q2_target =self.critic_target(next_states, next_actions) q_target = torch.min(q1_target, q2_target) ifself.twin_q else q1_target# Cible avec entropie y = rewards +self.gamma * (1- dones) * ( q_target -self.alpha * next_log_probs ) q1, q2 =self.critic(states, actions) critic_loss = F.mse_loss(q1, y)ifself.twin_q: critic_loss = critic_loss + F.mse_loss(q2, y)# Temoin de surestimation : moyenne de Q1 sur le lot, AVANT le pas# de gradient (l'ablation section 5 compare sa derive entre twin et# single-Q) q1_mean_val = q1.mean().item()self.critic_optimizer.zero_grad() critic_loss.backward()self.critic_optimizer.step()# --- Mise a jour de l'Actor --- new_actions, log_probs, _ =self.policy(states) q1_new, q2_new =self.critic(states, new_actions) q_new = torch.min(q1_new, q2_new) ifself.twin_q else q1_new policy_loss = (self.alpha * log_probs - q_new).mean()self.policy_optimizer.zero_grad() policy_loss.backward()self.policy_optimizer.step()# --- Mise a jour de la temperature --- alpha_loss =-(self.log_alpha * (log_probs +self.target_entropy).detach()).mean()self.alpha_optimizer.zero_grad() alpha_loss.backward()self.alpha_optimizer.step()# --- Soft update des target networks ---for param, target_param inzip(self.critic.parameters(),self.critic_target.parameters()): target_param.data.copy_(self.tau * param.data + (1-self.tau) * target_param.data )return {'critic_loss': critic_loss.item(),'policy_loss': policy_loss.item(),'alpha': self.alpha.item(),'alpha_loss': alpha_loss.item(),'q1_mean': q1_mean_val }# Verificationagent = SACAgent(state_dim, action_dim, hidden_dim=64)test_action_env = agent.select_action(np.random.randn(state_dim))print(f"SACAgent defini : hidden=64, gamma=0.99, tau=0.005, lr=3e-4")print(f"Action de test : {test_action_env} (dans [-{max_action}, {max_action}])")print(f"Alpha initial : {agent.alpha.item():.4f} | Entropie cible : {agent.target_entropy}")print(f"Politique : {sum(p.numel() for p in agent.policy.parameters())} params")print(f"Critic : {sum(p.numel() for p in agent.critic.parameters())} params "f"(twin_q={agent.twin_q})")
\(\min(Q_1, Q_2)\) reduit la surestimation sans introduire de biais negatif
Reparameterization
rsample() permet les gradients a travers l’echantillonnage
Auto-alpha
log_alpha est un paramètre apprenable, alpha = exp(log_alpha) reste positif
Tanh squashing
Les actions sont projetees dans \([-1, 1]\) puis mises a l’echelle de l’environnement
5. Entrainement sur Pendulum-v1
La boucle d’entrainement suit le schema classique off-policy : 1. Collecter une transition \((s, a, r, s', done)\) dans l’environnement 2. La stocker dans le replay buffer 3. Echantillonner un mini-lot et mettre a jour les reseaux 4. Repeter
Nous entrainons pendant 200 episodes (rappel : scafold court, CPU-only).
Subtilite importante : terminated vs truncated dans le replay buffer
Gymnasium (>= 0.26) decompose la fin d’un episode en deux signaux distincts, et SAC y est particulierement sensible car le replay buffer stocke un booleen done utilise comme masque de bootstrap dans la cible de Bellman \(y = r + \gamma (1 - done)[\min(Q_1', Q_2') - \alpha \log \pi']\) :
terminated = True : fin naturelle (l’agent est mort, la tache a echoue ou reussi). Il n’y a pas d’etat suivant exploitable : le terme \(Q(s')\) doit etre annule (\(done = 1\)), sinon l’agent apprendrait depuis un etat impossible.
truncated = True : l’episode est coupe par timeout (ex. Pendulum a 200 pas), mais l’agent est encore vivant. L’etat \(s'\) est parfaitement valide, et \(Q(s')\) est un bootstrap legitime vers le futur. Annuler ce bootstrap (\(done = 1\)) biaiserait systematiquement la cible vers une valeur myope (\(y \approx r\)).
Piege classique : ecrire done = terminated or truncated puis stocker done dans le replay buffer. Cela traite chaque timeout comme une fin naturelle et tue le bootstrap a chaque frontiere d’episode. Pour une tache a horizon infini comme Pendulum-v1 (qui ne termine jamais naturellement, terminated est toujours False), cela signifie que la derniere transition de chaque episode apprend une cible sans valeur future – alors que le pendule, lui, continuerait physiquement au-dela du pas 200.
Correction adoptee ci-dessous : on stocke float(terminated) (et non float(done)) dans le replay buffer. La variable done (=terminated or truncated) continue de piloter le controle de boucle (reset de l’environnement), mais seul un vrai etat terminal annule le bootstrap. C’est la meme convention que dans les notebooks jumeaux rl_6 (DQN), rl_6b (A2C) et rl_6c (PPO) de cette serie.
Pour aller plus loin : ce detail est documente dans la spec Gymnasium ([https://gymnasium.farama.org/] - “Terminated and Truncated”) et discute par Sutton & Barto (chapitre sur le bootstrapping aux frontieres d’episode). Le retrogradage d’un vieux code Gym (done unique) vers Gymnasium est une source frequente de bugs silencieux dans les implementations SAC/DQN from-scratch.
def train_sac(env_name="Pendulum-v1", num_episodes=200, batch_size=64, replay_size=10000, start_steps=1000, print_every=50, hidden_dim=64, seed=42, twin_q=True):"""Entraine un agent SAC sur un environnement continu. twin_q=False coupe l'ablation single-Q (section 5). La valeur moyenne de Q1 sur les mini-lots de chaque episode est journalisee dans agent.q_log (temoin du biais de surestimation). """# Reproductibilite torch.manual_seed(seed) np.random.seed(seed) env = gym.make(env_name) s_dim = env.observation_space.shape[0] a_dim = env.action_space.shape[0] max_a =float(env.action_space.high[0]) agent = SACAgent(s_dim, a_dim, hidden_dim=hidden_dim, twin_q=twin_q)buffer= ReplayBuffer(replay_size, s_dim, a_dim) episode_rewards = [] q_log = [] episode_q1_means = [] total_steps =0 episode =0 state, _ = env.reset(seed=seed) episode_reward =0while episode < num_episodes:# Phase d'exploration initiale (actions aleatoires)if total_steps < start_steps: action = env.action_space.sample()else: action = agent.select_action(state, deterministic=False) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated# Normaliser l'action pour le buffer (dans [-1, 1]) action_norm = action / max_a# On stocke `terminated` (PAS `done`) dans le replay buffer : seul un# vrai etat terminal doit annuler le bootstrap de la Q-cible via le# masque (1 - dones) dans SACAgent.update. Une troncature (timeout,# truncated=True) n'est PAS une fin naturelle -- l'agent est encore# vivant et la valeur Q(s') reste un bootstrap valide. Convention# identique a rl_6 (DQN), rl_6b (A2C) et rl_6c (PPO). Pour Pendulum-v1# (tache a horizon infini, terminated toujours False), le buffer ne# porte donc jamais le signal "fin d'episode = zero bootstrap", et# l'agent apprend la vraie valeur a horizon infini au lieu de# traiter chaque timeout a 200 pas comme terminal. `done` (terminated# OR truncated) reste utilise ci-dessous pour le controle de boucle# (reset d'environnement).buffer.push(state, action_norm, reward, next_state, float(terminated)) state = next_state episode_reward += reward total_steps +=1# Mise a jour si assez de donneesifbuffer.size >= batch_size: batch =buffer.sample(batch_size) update_stats = agent.update(batch) episode_q1_means.append(update_stats['q1_mean'])if done: episode_rewards.append(episode_reward)if episode_q1_means: q_log.append(float(np.mean(episode_q1_means))) episode_q1_means = [] episode +=1 state, _ = env.reset() episode_reward =0if episode % print_every ==0: recent = episode_rewards[-print_every:] mean_r = np.mean(recent) alpha_val = agent.alpha.item()print(f"Episode {episode:3d}/{num_episodes} | "f"Mean reward: {mean_r:7.1f} | "f"Alpha: {alpha_val:.4f} | "f"Steps: {total_steps}") env.close() agent.q_log = q_logreturn agent, episode_rewardsprint("Fonction train_sac definie : 200 episodes, batch=64, replay=10000")
Fonction train_sac definie : 200 episodes, batch=64, replay=10000
# Entrainement SACsac_agent, sac_rewards = train_sac(num_episodes=200, print_every=50)print(f"\nEntrainement termine : {len(sac_rewards)} episodes")print(f"Recompense moyenne (derniers 50) : {np.mean(sac_rewards[-50:]):.1f}")print(f"Recompense max : {np.max(sac_rewards):.0f}")print(f"Alpha final : {sac_agent.alpha.item():.4f}")
Episode 50/200 | Mean reward: -798.2 | Alpha: 0.2142 | Steps: 10000
Episode 100/200 | Mean reward: -271.0 | Alpha: 0.0774 | Steps: 20000
Episode 150/200 | Mean reward: -214.0 | Alpha: 0.0701 | Steps: 30000
Episode 200/200 | Mean reward: -155.6 | Alpha: 0.0365 | Steps: 40000
Entrainement termine : 200 episodes
Recompense moyenne (derniers 50) : -155.6
Recompense max : -0
Alpha final : 0.0365
Visualisation generee. Reward moyen final (20 ep) : -178.6
Sortie obtenue : la courbe montre une progression typique de SAC sur Pendulum-v1.
Metrique
Valeur attendue
Signification
Reward initial
~-1600 a -800
Politique aleatoire, pendule tombe
Reward final (50 ep)
~-200 a -100
Pendule equilibre la plupart du temps
Alpha final
~0.1-0.3
Temperature auto-adjustee
Convergence
Episode 100-150
SAC apprend vite grace au replay buffer
Points cles : 1. SAC est off-policy : il reutilise les expériences passees, donc il apprend plus vite qu’A2C ou PPO 2. La temperature alpha est auto-adjustee par l’agent (elle fluctue au fil de l’entrainement selon le besoin d’exploration, cf. trace Alpha ci-dessus) 3. Pendulum-v1 est un problème de contrôle continu : impossible avec DQN pur
Ablation executee : le twin-Q reduit-il vraiment la surestimation ?
La section precedente presente l’avantage twin-Q (« reduit la surestimation », tableau de synthese) sur le run unique ci-dessus, sans isolement experimental. Comme en rl_6, rl_6b et rl_6c, on mesure : 5 graines {42, 0, 1, 7, 99} × deux conditions strictement alignees (meme buffer, memes hyperparametres, 200 episodes) :
twin Q : SAC tel que defini, cible \(y = r + \gamma[\min(Q_1', Q_2') - \alpha \log \pi']\) ;
single Q : twin_q=False – la cible et l’actor ne lisent que \(Q_1\), seule sa perte est minimisee.
Le biais de surestimation est suivi par le temoin q1_mean journalise pendant l’entrainement : la moyenne de \(Q_1\) sur les mini-lots de chaque episode. Le mecanisme attendu (Fujimoto et al. 2018) : la cible \(\max\)-biaisee (ici via un seul estimateur bruite) se propage de bootstrap en bootstrap, et \(Q_1\) derive au-dela de la valeur realisable ; \(\min(Q_1, Q_2)\) garde la cible proche de l’estimation la plus pessimiste. Si l’effet est faible sur Pendulum a 200 episodes, c’est un resultat a rapporter tel quel – pas a masquer.
ABL_SEEDS = [42, 0, 1, 7, 99]ABL_CONDITIONS = {"twin Q": True, "single Q": False}sac_abl = {cond: {} for cond in ABL_CONDITIONS}sac_abl_qlogs = {cond: {} for cond in ABL_CONDITIONS}for cond, tq in ABL_CONDITIONS.items():for s in ABL_SEEDS: abl_agent, curve = train_sac(num_episodes=200, print_every=200, seed=s, twin_q=tq) sac_abl[cond][s] = curve sac_abl_qlogs[cond][s] = abl_agent.q_logprint(f"{cond:9s} seed={s:2d} | final (50 dern. ep) : "f"{np.mean(curve[-50:]):8.1f} | Q1 final : "f"{np.mean(abl_agent.q_log[-10:]):8.1f}")
Episode 200/200 | Mean reward: -367.9 | Alpha: 0.0404 | Steps: 40000
twin Q seed=42 | final (50 dern. ep) : -150.9 | Q1 final : -36.8
Episode 200/200 | Mean reward: -374.5 | Alpha: 0.0668 | Steps: 40000
twin Q seed= 0 | final (50 dern. ep) : -202.4 | Q1 final : -46.1
Episode 200/200 | Mean reward: -338.1 | Alpha: 0.0690 | Steps: 40000
twin Q seed= 1 | final (50 dern. ep) : -117.4 | Q1 final : -23.8
Episode 200/200 | Mean reward: -326.6 | Alpha: 0.0399 | Steps: 40000
twin Q seed= 7 | final (50 dern. ep) : -164.5 | Q1 final : -33.1
Episode 200/200 | Mean reward: -322.0 | Alpha: 0.0486 | Steps: 40000
twin Q seed=99 | final (50 dern. ep) : -154.9 | Q1 final : -28.5
Episode 200/200 | Mean reward: -409.6 | Alpha: 0.1001 | Steps: 40000
single Q seed=42 | final (50 dern. ep) : -368.2 | Q1 final : -41.8
Episode 200/200 | Mean reward: -313.7 | Alpha: 0.0605 | Steps: 40000
single Q seed= 0 | final (50 dern. ep) : -146.9 | Q1 final : -9.8
Episode 200/200 | Mean reward: -326.6 | Alpha: 0.0517 | Steps: 40000
single Q seed= 1 | final (50 dern. ep) : -117.3 | Q1 final : -12.2
Episode 200/200 | Mean reward: -379.8 | Alpha: 0.0290 | Steps: 40000
single Q seed= 7 | final (50 dern. ep) : -162.6 | Q1 final : -19.8
Episode 200/200 | Mean reward: -277.5 | Alpha: 0.0586 | Steps: 40000
single Q seed=99 | final (50 dern. ep) : -155.9 | Q1 final : -9.9
sac_abl_stats = {}for cond in ABL_CONDITIONS: finals = [float(np.mean(sac_abl[cond][s][-50:])) for s in ABL_SEEDS] q_finals = [float(np.mean(sac_abl_qlogs[cond][s][-10:])) for s in ABL_SEEDS] sac_abl_stats[cond] = {"finals": finals,"p25": float(np.percentile(finals, 25)),"median": float(np.median(finals)),"p75": float(np.percentile(finals, 75)),"q_finals": q_finals,"q_median": float(np.median(q_finals)), }print(f"{cond:9s} | reward final : mediane {sac_abl_stats[cond]['median']:8.1f} "f"[IQR {sac_abl_stats[cond]['p25']:8.1f} - {sac_abl_stats[cond]['p75']:8.1f}] "f"| Q1 final : mediane {sac_abl_stats[cond]['q_median']:8.1f}")print(f"{'':9s} | rewards par graine : "+", ".join(f"{v:.0f}"for v in finals))fig, axes = plt.subplots(1, 2, figsize=(14, 5))sac_colors = {"twin Q": "#1565C0", "single Q": "#E65100"}for cond in ABL_CONDITIONS:for s in ABL_SEEDS: axes[0].plot(sac_abl_qlogs[cond][s], color=sac_colors[cond], alpha=0.25, linewidth=1) min_len =min(len(sac_abl_qlogs[cond][s]) for s in ABL_SEEDS) stack = np.stack([sac_abl_qlogs[cond][s][:min_len] for s in ABL_SEEDS]) axes[0].plot(np.median(stack, axis=0), color=sac_colors[cond], linewidth=2.2, label=f"{cond} (mediane, n=5)")axes[0].set_xlabel("Episode")axes[0].set_ylabel("Q1 moyen sur les mini-lots")axes[0].set_title("Derive du temoin Q1 (clair = 1 graine)")axes[0].legend()axes[0].grid(alpha=0.3)for i, cond inenumerate(ABL_CONDITIONS): st = sac_abl_stats[cond] axes[1].plot([i, i], [st["p25"], st["p75"]], color="black", linewidth=2.5) axes[1].plot(i, st["median"], "o", color=sac_colors[cond], markersize=11, zorder=3)for j, v inenumerate(st["finals"]): axes[1].plot(i + (j -2) *0.05, v, ".", color="black", markersize=5, alpha=0.6)axes[1].set_xticks([0, 1])axes[1].set_xticklabels(list(ABL_CONDITIONS))axes[1].set_ylabel("Reward final (50 dernieres ep)")axes[1].set_title("Dispersion inter-graines (trait = IQR, rond = mediane)")axes[1].grid(alpha=0.3, axis="y")plt.tight_layout()plt.show()
twin Q | reward final : mediane -154.9 [IQR -164.5 - -150.9] | Q1 final : mediane -33.1
| rewards par graine : -151, -202, -117, -164, -155
single Q | reward final : mediane -155.9 [IQR -162.6 - -146.9] | Q1 final : mediane -12.2
| rewards par graine : -368, -147, -117, -163, -156
Interpretation de la mesure
Condition
Reward final mediane
IQR
Finales par graine
Témoin Q1 final (mediane)
twin Q
-154.9
-164.5 a -150.9
-151, -202, -117, -164, -155
-33.1
single Q
-155.9
-162.6 a -146.9
-368, -147, -117, -163, -156
-12.2
Verdict honnete, en trois temps :
Sur la mediane, l’ablation n’ameliore rien. -154.9 contre -155.9 : sur Pendulum-v1 a 200 episodes, un seul critic fait aussi bien que deux dans la moitie des cas. C’est le resultat qu’il faut rapporter tel quel – le twin-Q n’achete pas de performance mediane ici.
Le temoin Q1 montre l’ecart d’optimisme attendu. Le critic unique croit a une valeur mediane de -12.2 quand le jumeau pessimiste en croit -33.1 : les deux ont des recompenses reelles identiques, donc l’ecart ~21 points est bien du biais, pas de la valeur. C’est la signature mesurable de la derivation du bootstrap : la cible min(Q1', Q2') tire l’estimation vers le bas, le critic seul s’auto-alimente son propre optimisme.
Le risque de queue differe. La condition single-Q produit une graine catastrophique (-368, seed 42) sur cinq ; la condition twin-Q n’en produit aucune. Avec n=5, c’est une indication, pas une preuve – mais elle va dans le sens de Fujimoto et al. (2018) : la protection du min se voit d’abord sur les trajectoires qui degenerent, pas sur la mediane.
En resume : le claim exact que ces mesures soutiennent est « le twin-Q reduit l’optimisme du critic et elimine (ici) la queue de degenerescence, sans gain median sur ce benchmark court » – plus etroit que le « reduit la surestimation » du tableau de synthese, mais desormais mesure.
6. Exercices
Exercice 1 : Planification de la temperature
Dans cette version de SAC, la temperature alpha est apprise automatiquement. Implementez une planification manuelle de alpha qui decroit lineairement au fil de l’entrainement.
Objectif : observer comment le profil d’exploration affecte la convergence.
Indices : - # Indice : Alpha initialement eleve (ex: 0.5) encourage l’exploration ; alpha final bas (ex: 0.05) favorise l’exploitation - # Indice : Vous pouvez modifier la boucle d’entrainement pour calculer alpha en fonction du numéro d’episode - # Indice : Comparez avec la courbe SAC auto-tune obtenue ci-dessus
Étapes : - # Étape 1 : Modifiez SACAgent pour accepter un alpha fixe (desactivez auto-tuning) - # Étape 2 : Dans train_sac, calculez alpha = alpha_start + (alpha_end - alpha_start) * episode / num_episodes - # Étape 3 : Lancez l’entrainement et comparez la courbe avec auto-tuning
# Exercice 1 : Planification lineaire de la temperature alpha## Implementez une version de SAC avec alpha decroissant lineairement.## Indice : Partez de SACAgent et ajoutez un flag use_auto_alpha=False# Indice : Dans la boucle d'entrainement, calculez alpha a chaque episode# Etape 1 : Creez une classe SACAgentFixedAlpha ou ajoutez un parametre au SACAgent# Etape 2 : Ecrivez une boucle d'entrainement qui ajuste alpha lineairement# Etape 3 : Tracez la courbe et comparez avec auto-tuningdef train_sac_linear_alpha(env_name="Pendulum-v1", num_episodes=200, alpha_start=0.5, alpha_end=0.05):""" Entraine SAC avec temperature decroissant lineairement. # Indice : alpha_schedule = alpha_start + (alpha_end - alpha_start) * episode / num_episodes # Etape 1 : Initialisez l'agent et le buffer # Etape 2 : Dans la boucle, forcez agent.log_alpha = log(alpha_schedule) # Etape 3 : Enregistrez les rewards et retournez la liste """ rewards =None# TODO etudiant : retourner la liste des rewardsreturn rewardsprint("Exercice a completer : implementer la planification lineaire de alpha")
Exercice a completer : implementer la planification lineaire de alpha
Exercice 2 : Ablation des twin Q-networks
SAC utilise deux Q-networks et prend le minimum pour reduire la surestimation. Comparez SAC avec un seul Q-network (sans le min) et la version twin.
Objectif : mesurer l’impact du clipped double Q-learning sur la stabilite.
Indices : - # Indice : Dans SACAgent.update(), remplacez torch.min(q1_target, q2_target) par q1_target uniquement - # Indice : Vous pouvez aussi ne mettre a jour que Q1 et laisser Q2 inutilise - # Indice : Observez si la courbe de reward est plus instable ou plus basse
Étapes : - # Étape 1 : Créez un agent SAC avec un seul Q-network (ou ignorez Q2) - # Étape 2 : Entrainez sur Pendulum-v1 avec les mêmes hyperparametres - # Étape 3 : Superposez les deux courbes de reward (twin vs single)
# Exercice 2 : Ablation twin Q-networks## Comparez SAC avec twin Q (min Q1, Q2) vs single Q (Q1 uniquement).## Indice : Modifiez la methode update() de SACAgent pour n'utiliser que Q1# Indice : Gardez les memes hyperparametres pour une comparaison equitable# Etape 1 : Creez une version SingleQSACAgent ou modifiez SACAgent.update# Etape 2 : Entrainez sur 200 episodes# Etape 3 : Tracez les deux courbes (twin vs single) sur le meme graphedef train_sac_single_q(env_name="Pendulum-v1", num_episodes=200):""" Entraine SAC avec un seul Q-network (pas de min Q1,Q2). # Indice : Utilisez uniquement Q1 pour la cible et la mise a jour # Etape 1 : Initialisez un agent SAC (vous pouvez reutiliser TwinQNetwork en ignorant Q2) # Etape 2 : Dans update(), utilisez q1_target au lieu de min(q1_target, q2_target) # Etape 3 : Comparez les resultats avec la version twin """ rewards =None# TODO etudiant : retourner la liste des rewardsreturn rewardsprint("Exercice a completer : ablation twin Q-networks")
Exercice a completer : ablation twin Q-networks
Exercice 3 : Adapter SAC a un autre environnement
Adaptez l’agent SAC pour fonctionner sur LunarLanderContinuous-v2 (atterrissage d’un module lunaire avec actions continues). Cet environnement a un espace d’observation différent (dim=8) et un espace d’action différent (dim=2, dans \([-1, 1]\)).
Objectif : verifier que l’implementation SAC est generique et fonctionne sur différents environnements.
Indices : - # Indice : LunarLanderContinuous-v2 : state_dim=8, action_dim=2, max_action=1.0 - # Indice : L’environnement peut nécessiter l’installation du package gymnasium[box2d] - # Indice : Si LunarLanderContinuous-v2 n’est pas disponible, utilisez Pendulum-v1 avec un hidden_dim différent (32 puis 128) et comparez
Étapes : - # Étape 1 : Créez l’environnement LunarLanderContinuous-v2 (ou Pendulum avec hidden_dim modifie) - # Étape 2 : Initialisez un SACAgent avec les bonnes dimensions - # Étape 3 : Entrainez et observez si la convergence est similaire
# Exercice 3 : Adapter SAC a LunarLanderContinuous-v2## Modifiez les dimensions et parametres pour un nouvel environnement.## Indice : Verifiez d'abord si l'environnement est disponible# Indice : Si box2d n'est pas installe, utilisez Pendulum avec hidden_dim=32 et 128# Etape 1 : Creez l'environnement et relevez les dimensions# Etape 2 : Initialisez un SACAgent adapte# Etape 3 : Entrainez et comparez avec Pendulum-v1try: test_env = gym.make("LunarLanderContinuous-v2") target_env ="LunarLanderContinuous-v2" test_env.close()exceptException: target_env ="Pendulum-v1 (hidden_dim=32)"print("LunarLanderContinuous-v2 non disponible, utilisez Pendulum avec hidden_dim=32")print(f"Environnement cible : {target_env}")print("Exercice a completer : adapter SAC a un nouvel environnement")
LunarLanderContinuous-v2 non disponible, utilisez Pendulum avec hidden_dim=32
Environnement cible : Pendulum-v1 (hidden_dim=32)
Exercice a completer : adapter SAC a un nouvel environnement
7. Comparaison SAC vs DQN / A2C / PPO
Après avoir implemente SAC, voici une synthese des algorithmes couverts dans cette serie :
Aspect
DQN
REINFORCE
A2C
PPO
SAC
Paradigme
Value-based
Policy-based
Actor-Critic
Actor-Critic
Actor-Critic
Policy
Epsilon-greedy
Stochastique
Stochastique
Stochastique
Gaussienne + tanh
On/Off-policy
Off-policy
On-policy
On-policy
On-policy
Off-policy
Actions
Discretes
Disc. + Cont.
Disc. + Cont.
Disc. + Cont.
Continues
Sample efficiency
Elevee
Faible
Moyenne
Moyenne
Elevee
Entropie
N/A
Bonus optionnel
Bonus fixe
Bonus fixe
Maximisee (alpha auto)
Replay buffer
Oui
Non
Non
Non
Oui
Target network
Oui (hard copy)
Non
Non
Non
Oui (soft update)
Robustesse
Moyenne
Faible
Moyenne
Bonne
Très bonne
Quand utiliser SAC ? - Actions continues (robotique, contrôle moteur, trading) - Sample efficiency importante (peu d’interactions avec l’environnement) - Robustesse aux hyperparametres (SAC est tolerante aux mauvais choix de lr)
Application : RL pour le trading algorithmique
Le RL avec actions continues est directement applicable au trading algorithmique. Dans un contexte financier :
Etat : prix de marche, indicateurs techniques, position du portefeuille
Action : quantite a acheter/vendre (action continue, comme dans SAC)
Recompense : rendement ajuste du risque (Sharpe ratio, PnL)
Environnement : simulateur de marche avec couts de transaction
Les algorithmes off-policy comme SAC sont particulierement adaptes : le replay buffer permet de reutiliser les données historiques de marche, et la gestion automatique de la temperature aide a explorer les stratégies dans un environnement non-stationnaire.
Pour approfondir : la serie QuantConnect implemente des stratégies de trading algorithmique. Le pipeline ML-Training-Pipeline entraine notamment des modèles RL (PPO, Decision Transformer) pour l’allocation de portefeuille. Pour voir le RL (PPO/SAC) applique a un environnement de marche concret, le notebook research_rl_ppo construit un agent de trading RL bout-en-bout (etat = fenêtre de prix, action = signal long/short, recompense = Sharpe ajuste du risque).
Conclusion
Dans ce notebook, nous avons :
Concept
Ce que nous avons fait
RL a entropie maximale
Formula mathematique et visualisation de l’entropie
Politique gaussienne
Implementation avec squashing tanh et correction log_prob
Twin Q-networks
Double critique pour reduire la surestimation
Temperature automatique
Alpha appris comme paramètre reseau
Algorithme SAC complet
Off-policy avec replay buffer et soft update
Points cles a retenir
L’entropie maximale est une regularisation naturelle qui rend SAC robuste et exploratoire
Off-policy + replay buffer = sample efficient. Chaque expérience est reutilisee plusieurs fois
Le min(Q1, Q2) reduit le biais de surestimation sans le eliminer completement
La temperature auto adapte le niveau d’exploration au fil de l’apprentissage
Pour aller plus loin
TD3 : Twin Delayed DDPG, autre algorithme off-policy pour actions continues
SAC + HER : SAC combine avec Hindsight Experience Replay pour les tâches a objectifs
SAC discret : Adaptation de SAC pour les espaces d’actions discrets
Multi-agent : Le notebook RL-7 Multi-Agent aborde le RL multi-agent
References
Haarnoja et al. (2018) - Soft Actor-Critic: Off-Policy Maximum Entropy Deep RL with a Stochastic Actor
Haarnoja et al. (2018) - Soft Actor-Critic Algorithms and Applications
Sutton & Barto, Reinforcement Learning: An Introduction, Chapter 13