xauusd_bot/ppo_agent.py

419 lines
17 KiB
Python

# ============================================================
# ppo_agent.py — Agent PPO avec support AMD DirectML
# ============================================================
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import os
import logging
from typing import List, Tuple, Optional, Dict
import config
logger = logging.getLogger(__name__)
# ── Device Setup (AMD RX 6700 XT via DirectML) ────────────────
def get_device(force_cpu: bool = False) -> torch.device:
"""
Retourne le device de calcul optimal.
force_cpu=True : utilisé pendant l'entraînement (DirectML ne supporte
pas toutes les opérations backward de PPO).
force_cpu=False : utilisé en inférence live (DirectML OK).
"""
if force_cpu:
logger.info("Device : CPU (mode entrainement - DirectML incompatible avec PPO backward)")
return torch.device("cpu")
if config.USE_DIRECTML:
try:
import torch_directml
device = torch_directml.device()
logger.info(f"AMD DirectML active : {device}")
return device
except ImportError:
logger.warning("torch-directml non trouve. Fallback CPU.")
except Exception as e:
logger.warning(f"DirectML indisponible ({e}). Fallback CPU.")
if torch.cuda.is_available():
logger.info("CUDA disponible - GPU NVIDIA.")
return torch.device("cuda")
logger.info("Device : CPU")
return torch.device("cpu")
# ── Réseau Actor-Critic ────────────────────────────────────────
class ActorCriticNetwork(nn.Module):
"""
Réseau de neurones partagé Actor-Critic pour PPO.
Architecture :
Input → LSTM → Couches Fully-Connected → [Actor Head | Critic Head]
- Actor Head : distribution de probabilité sur les 4 actions
- Critic Head : estimation de la valeur (V(s))
"""
def __init__(self, obs_size: int, n_actions: int = 4):
super().__init__()
hidden = config.HIDDEN_SIZE
# ── Couche d'embedding ─────────────────────────────────
self.embedding = nn.Sequential(
nn.Linear(obs_size, hidden),
nn.LayerNorm(hidden),
nn.LeakyReLU(0.01),
nn.Dropout(config.DROPOUT),
)
# ── Couches communes (trunk) ───────────────────────────
self.trunk = nn.Sequential(
nn.Linear(hidden, hidden),
nn.LayerNorm(hidden),
nn.LeakyReLU(0.01),
nn.Dropout(config.DROPOUT),
nn.Linear(hidden, hidden // 2),
nn.LayerNorm(hidden // 2),
nn.LeakyReLU(0.01),
)
# ── Actor Head ─────────────────────────────────────────
self.actor_head = nn.Sequential(
nn.Linear(hidden // 2, hidden // 4),
nn.LeakyReLU(0.01),
nn.Linear(hidden // 4, n_actions),
)
# ── Critic Head ────────────────────────────────────────
self.critic_head = nn.Sequential(
nn.Linear(hidden // 2, hidden // 4),
nn.LeakyReLU(0.01),
nn.Linear(hidden // 4, 1),
)
# Initialisation orthogonale (recommandée pour PPO)
self._init_weights()
def _init_weights(self):
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.orthogonal_(module.weight, gain=np.sqrt(2))
nn.init.constant_(module.bias, 0.0)
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Retourne (logits_actions, valeur_état)."""
emb = self.embedding(x)
trunk = self.trunk(emb)
logits = self.actor_head(trunk)
value = self.critic_head(trunk).squeeze(-1)
return logits, value
def get_action_and_value(
self, x: torch.Tensor, action: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Retourne (action, log_prob, entropy, value).
Si action=None, échantillonne une nouvelle action.
"""
logits, value = self.forward(x)
dist = torch.distributions.Categorical(logits=logits)
if action is None:
action = dist.sample()
log_prob = dist.log_prob(action)
entropy = dist.entropy()
return action, log_prob, entropy, value
# ── Rollout Buffer ─────────────────────────────────────────────
class RolloutBuffer:
"""Stocke les expériences collectées avant la mise à jour PPO."""
def __init__(self, rollout_steps: int, obs_size: int, device):
self.rollout_steps = rollout_steps
self.obs_size = obs_size
self.device = device
self.reset()
def reset(self):
self.observations = np.zeros((self.rollout_steps, self.obs_size), dtype=np.float32)
self.actions = np.zeros(self.rollout_steps, dtype=np.int64)
self.log_probs = np.zeros(self.rollout_steps, dtype=np.float32)
self.rewards = np.zeros(self.rollout_steps, dtype=np.float32)
self.values = np.zeros(self.rollout_steps, dtype=np.float32)
self.dones = np.zeros(self.rollout_steps, dtype=np.float32)
self.ptr = 0
def store(
self,
obs: np.ndarray,
action: int,
log_prob: float,
reward: float,
value: float,
done: bool
):
self.observations[self.ptr] = obs
self.actions[self.ptr] = action
self.log_probs[self.ptr] = log_prob
self.rewards[self.ptr] = reward
self.values[self.ptr] = value
self.dones[self.ptr] = float(done)
self.ptr += 1
def is_full(self) -> bool:
return self.ptr >= self.rollout_steps
def compute_returns_and_advantages(
self,
last_value: float,
gamma: float = config.PPO_GAMMA,
gae_lambda: float = config.PPO_GAE_LAMBDA
) -> Tuple[np.ndarray, np.ndarray]:
"""Calcule les returns et advantages via GAE (Generalized Advantage Estimation)."""
advantages = np.zeros_like(self.rewards)
last_gae = 0.0
for t in reversed(range(self.rollout_steps)):
if t == self.rollout_steps - 1:
next_val = last_value
next_done = 0.0
else:
next_val = self.values[t + 1]
next_done = self.dones[t + 1]
delta = self.rewards[t] + gamma * next_val * (1 - next_done) - self.values[t]
last_gae = delta + gamma * gae_lambda * (1 - next_done) * last_gae
advantages[t] = last_gae
returns = advantages + self.values
return returns, advantages
def get_batches(
self,
returns: np.ndarray,
advantages: np.ndarray,
batch_size: int = config.PPO_BATCH_SIZE
):
"""Génère des mini-batches aléatoires."""
indices = np.random.permutation(self.rollout_steps)
for start in range(0, self.rollout_steps, batch_size):
batch_idx = indices[start:start + batch_size]
yield (
torch.FloatTensor(self.observations[batch_idx]).to(self.device),
torch.LongTensor(self.actions[batch_idx]).to(self.device),
torch.FloatTensor(self.log_probs[batch_idx]).to(self.device),
torch.FloatTensor(returns[batch_idx]).to(self.device),
torch.FloatTensor(advantages[batch_idx]).to(self.device),
)
# ── Agent PPO ─────────────────────────────────────────────────
class PPOAgent:
"""
Agent Proximal Policy Optimization (PPO) complet.
- Entrainement : CPU (DirectML ne supporte pas tous les ops backward)
- Inference live : AMD DirectML (RX 6700 XT)
"""
def __init__(self, obs_size: int, n_actions: int = 4, training_mode: bool = False):
self.device = get_device(force_cpu=training_mode)
self.network = ActorCriticNetwork(obs_size, n_actions).to(self.device)
self.optimizer = optim.Adam(
self.network.parameters(),
lr=config.PPO_LR,
eps=1e-5
)
self.buffer = RolloutBuffer(config.PPO_ROLLOUT_STEPS, obs_size, self.device)
self.n_actions = n_actions
# Métriques d'entraînement
self.total_steps = 0
self.training_history = {
"policy_loss": [], "value_loss": [],
"entropy": [], "kl_div": [],
"clip_frac": [],
}
logger.info(
f"🧠 Agent PPO initialisé | Device: {self.device} | "
f"Paramètres: {sum(p.numel() for p in self.network.parameters()):,}"
)
# ── Inférence ──────────────────────────────────────────────
@torch.no_grad()
def predict(self, obs: np.ndarray, deterministic: bool = False) -> Tuple[int, float, float]:
"""
Prédit une action à partir d'une observation.
Retourne (action, log_prob, value).
"""
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
action, log_prob, _, value = self.network.get_action_and_value(obs_t)
if deterministic:
logits, value = self.network(obs_t)
action = logits.argmax(dim=-1)
dist = torch.distributions.Categorical(logits=logits)
log_prob = dist.log_prob(action)
return int(action.item()), float(log_prob.item()), float(value.item())
@torch.no_grad()
def get_value(self, obs: np.ndarray) -> float:
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
_, value = self.network(obs_t)
return float(value.item())
# ── Entraînement ───────────────────────────────────────────
def collect_rollout(self, env) -> Dict:
"""Collecte PPO_ROLLOUT_STEPS steps d'expérience."""
self.buffer.reset()
obs, _ = env.reset()
done = False
ep_rewards = []
ep_reward = 0.0
while not self.buffer.is_full():
action, log_prob, value = self.predict(obs)
next_obs, reward, terminated, truncated, info = env.step(action)
self.buffer.store(obs, action, log_prob, reward, value, terminated or truncated)
obs = next_obs
ep_reward += reward
self.total_steps += 1
if terminated or truncated:
ep_rewards.append(ep_reward)
ep_reward = 0.0
obs, _ = env.reset()
# Valeur bootstrap du dernier état
last_value = self.get_value(obs)
returns, advantages = self.buffer.compute_returns_and_advantages(last_value)
return {
"mean_ep_reward": np.mean(ep_rewards) if ep_rewards else 0.0,
"returns": returns,
"advantages": advantages,
}
def update(self, returns: np.ndarray, advantages: np.ndarray) -> Dict:
"""Met à jour le réseau de neurones avec PPO."""
# Normaliser les advantages
adv_normalized = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
total_policy_loss = 0.0
total_value_loss = 0.0
total_entropy = 0.0
total_clip_frac = 0.0
n_batches = 0
for epoch in range(config.PPO_UPDATE_EPOCHS):
for obs_b, act_b, old_log_b, ret_b, adv_b in self.buffer.get_batches(
returns, adv_normalized
):
_, new_log_prob, entropy, new_value = self.network.get_action_and_value(obs_b, act_b)
# ── Policy Loss (PPO Clip) ─────────────────────
ratio = torch.exp(new_log_prob - old_log_b)
obj_clip = torch.clamp(ratio, 1 - config.PPO_CLIP_EPS, 1 + config.PPO_CLIP_EPS) * adv_b
obj_unclip = ratio * adv_b
policy_loss = -torch.min(obj_unclip, obj_clip).mean()
# ── Value Loss ─────────────────────────────────
value_loss = 0.5 * ((new_value - ret_b) ** 2).mean()
# ── Entropie (exploration) ─────────────────────
entropy_loss = entropy.mean()
# ── Loss Totale ────────────────────────────────
loss = (
policy_loss
+ config.PPO_VALUE_COEF * value_loss
- config.PPO_ENTROPY_COEF * entropy_loss
)
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.network.parameters(), 0.5)
self.optimizer.step()
# Métriques
clip_frac = ((ratio - 1.0).abs() > config.PPO_CLIP_EPS).float().mean().item()
total_policy_loss += policy_loss.item()
total_value_loss += value_loss.item()
total_entropy += entropy_loss.item()
total_clip_frac += clip_frac
n_batches += 1
metrics = {
"policy_loss": total_policy_loss / max(n_batches, 1),
"value_loss": total_value_loss / max(n_batches, 1),
"entropy": total_entropy / max(n_batches, 1),
"clip_frac": total_clip_frac / max(n_batches, 1),
}
for k, v in metrics.items():
self.training_history[k].append(v)
return metrics
# ── Sauvegarde / Chargement ────────────────────────────────
def save(self, path: str = config.MODEL_PATH):
"""Sauvegarde le modèle et l'état de l'optimiseur."""
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save({
"network_state": self.network.state_dict(),
"optimizer_state": self.optimizer.state_dict(),
"total_steps": self.total_steps,
"history": self.training_history,
}, path)
logger.info(f"💾 Modèle sauvegardé : {path} ({self.total_steps:,} steps)")
def load(self, path: str = config.MODEL_PATH) -> bool:
"""Charge un modèle pré-entraîné."""
if not os.path.exists(path):
logger.warning(f"Aucun modèle trouvé à {path}.")
return False
try:
# Toujours charger sur CPU d'abord pour éviter les conflits de device
checkpoint = torch.load(path, map_location=torch.device("cpu"))
self.network.load_state_dict(checkpoint["network_state"])
self.network.to(self.device)
# Charger l'optimizer séparément (peut causer des erreurs de device)
try:
self.optimizer.load_state_dict(checkpoint["optimizer_state"])
except Exception as opt_e:
logger.warning(f"Optimizer non chargé ({opt_e}) — réinitialisé")
self.optimizer = torch.optim.Adam(
self.network.parameters(), lr=config.PPO_LR
)
self.total_steps = checkpoint.get("total_steps", 0)
self.training_history = checkpoint.get("history", self.training_history)
logger.info(f"Modele charge : {path} ({self.total_steps:,} steps)")
return True
except Exception as e:
logger.error(f"Erreur chargement modele : {e}")
return False
def get_action_probabilities(self, obs: np.ndarray) -> np.ndarray:
"""Retourne les probabilités pour chaque action (debug/logging)."""
with torch.no_grad():
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
logits, _ = self.network(obs_t)
probs = torch.softmax(logits, dim=-1)
return probs.cpu().numpy().flatten()
ACTION_NAMES = {0: "HOLD", 1: "BUY", 2: "SELL", 3: "CLOSE"}