Série RL for Trading (#1461) | RL-1 Q-Learning · RL-2 PPO · RL-3 Reward Shaping · RL-4 Multi-Asset · RL-5 Overlay · RL-6 GRPO
Objectifs
Implémenter GRPO (Group Relative Policy Optimization), l’algorithme clé de DeepSeek-R1
GRPO élimine le besoin d’un critic séparé en utilisant des récompenses relatives intra-groupe
L’avantage est calculé par normalisation au sein d’un groupe de G trajectoires pour le même état
Appliquer au trading multi-actif sur le panier anti-biais 24 symbols
Validation multi-seed (0/1/7/42) avec edge ≥ 2σ
Pourquoi GRPO ?
Value function
Oui (Critic network)
Non (basé sur le groupe)
Avantage
GAE (λ-returns)
Normalisation intra-groupe
Complexité
2 réseaux
1 seul réseau
Stabilité
Clip + entropy
Clip + KL + baseline groupe
L’hypothèse GRPO : la relative performance d’une action par rapport aux alternatives au même instant est un signal plus robuste qu’une estimation absolue de valeur.
Durée estimée : ~6 min (Papermill) | Prérequis : PyTorch, panier L1/L2
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from pathlib import Path
import warnings
warnings.filterwarnings("ignore" )
# GRPO hyperparameters
SEEDS = [0 , 1 , 7 , 42 ]
LOOKBACK = 20
N_EPISODES = 120
EPISODE_LEN = 252
HIDDEN_DIM = 128
LR = 3e-4
CLIP_EPS = 0.2
KL_COEF = 0.05
ENTROPY_COEF = 0.01
GROUP_SIZE = 8 # G trajectories per state for advantage estimation
FEE_BPS = 10
BATCH_SIZE_EP = 64
DATA_PATH = Path("../datasets/panier/panier_close_all.csv" )
print (f"PyTorch { torch. __version__} , CUDA= { torch. cuda. is_available()} " )
print (f"GRPO config: group_size= { GROUP_SIZE} , kl_coef= { KL_COEF} , episodes= { N_EPISODES} " )
# Determinisme GPU (issue #16795) : une graine seule ne garantit rien --
# cuDNN choisit ses algorithmes de facon non deterministe par defaut.
torch.use_deterministic_algorithms(True )
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
import os
os.environ['CUBLAS_WORKSPACE_CONFIG' ] = ':4096:8'
print (f"Determinisme: use_deterministic_algorithms=True, cudnn.deterministic=True" )
PyTorch 2.6.0+cu124, CUDA=True
GRPO config: group_size=8, kl_coef=0.05, episodes=120
Determinisme: use_deterministic_algorithms=True, cudnn.deterministic=True
1. Chargement du panier
Même panier 24-actifs que RL-04/RL-05.
df_raw = pd.read_csv(DATA_PATH, index_col= 0 , parse_dates= True )
df_raw.index = pd.DatetimeIndex(df_raw.index)
df_raw = df_raw.sort_index()
exclude = [c for c in df_raw.columns if df_raw[c].notna().sum () < len (df_raw) * 0.5 ]
df = df_raw.drop(columns= exclude)
df = df.ffill().dropna()
n_assets = df.shape[1 ]
symbols = list (df.columns)
returns = df.pct_change().dropna()
print (f"Panier: { n_assets} assets, { len (returns)} business days" )
print (f"Range: { returns. index. min (). date()} to { returns. index. max (). date()} " )
Panier: 24 assets, 3096 business days
Range: 2017-11-10 to 2026-05-02
2. Environnement Portfolio (réutilisé de RL-04)
Allocation via Dirichlet (toujours valide : positif, somme=1).
class PortfolioEnv:
"""Multi-asset portfolio with Dirichlet policy."""
def __init__ (self , returns_df, lookback= 20 , episode_len= 252 , fee_bps= 10 ):
self .returns = returns_df.values
self .n_assets = returns_df.shape[1 ]
self .lookback = lookback
self .episode_len = episode_len
self .fee = fee_bps / 10000
self ._idx = None
self ._prev_weights = None
@property
def state_dim(self ):
return self .lookback * self .n_assets
@property
def action_dim(self ):
return self .n_assets
def reset(self , start_idx= None ):
max_start = len (self .returns) - self .lookback - self .episode_len - 1
if start_idx is None :
start_idx = np.random.randint(self .lookback, max (max_start, self .lookback + 1 ))
self ._idx = start_idx
self ._prev_weights = np.full(self .n_assets, 1.0 / self .n_assets)
return self ._get_state()
def _get_state(self ):
window = self .returns[self ._idx - self .lookback:self ._idx]
std = window.std(axis= 0 , keepdims= True ) + 1e-8
return (window / std).flatten()
def step(self , weights):
self ._idx += 1
done = self ._idx >= len (self .returns) - 1
day_ret = self .returns[self ._idx]
port_ret = np.dot(weights, day_ret)
bh_ret = day_ret.mean()
turnover = np.abs (weights - self ._prev_weights).sum ()
fee_cost = turnover * self .fee * 0.5
port_ret -= fee_cost
self ._prev_weights = weights.copy()
reward = (port_ret - bh_ret) * 100
info = {"port_ret" : port_ret, "bh_ret" : bh_ret}
return self ._get_state() if not done else np.zeros(self .state_dim), reward, done, info
3. Policy Network (Actor seul — pas de Critic)
GRPO n’a besoin que d’un policy network . L’avantage est estimé via les récompenses relatives au sein du groupe.
L’Actor utilise Softplus + concentration de base pour la distribution Dirichlet (poids toujours valides).
class GRPOPolicy(nn.Module):
"""Policy-only network for GRPO. Outputs Dirichlet concentrations."""
def __init__ (self , state_dim, action_dim, hidden_dim= 128 ):
super ().__init__ ()
self .backbone = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU()
)
self .concentration_head = nn.Linear(hidden_dim, action_dim)
self .base_concentration = nn.Parameter(torch.ones(action_dim))
def forward(self , state):
feat = self .backbone(state)
raw = self .concentration_head(feat)
concentrations = torch.nn.functional.softplus(raw) + self .base_concentration + 0.1
return concentrations
def sample_dirichlet(concentrations, n_samples= 1 ):
"""Sample portfolio weights from Dirichlet distribution."""
if n_samples == 1 :
dist = torch.distributions.Dirichlet(concentrations)
return dist.sample(), dist
dist = torch.distributions.Dirichlet(concentrations)
return dist.sample((n_samples,)), dist
def dirichlet_log_prob(dist, weights):
"""Log probability under Dirichlet."""
return dist.log_prob(weights)
4. GRPO Update
L’idée centrale de GRPO : 1. Pour chaque état, échantillonner G trajectoires (group) 2. Calculer l’avantage par normalisation intra-groupe : A_i = (R_i - mean(R)) / std(R) 3. Pas besoin de Critic — la baseline est implicite dans le groupe 4. PPO-clip + penalty KL vs old policy
def grpo_update(policy, optimizer, states, actions_group, rewards_group, old_log_probs, device,
clip_eps= 0.2 , kl_coef= 0.05 , entropy_coef= 0.01 ):
"""
GRPO update using group-relative advantages.
states: (batch, state_dim)
actions_group: (batch, G, action_dim) - G samples per state
rewards_group: (batch, G) - reward for each group member
old_log_probs: (batch, G) - log probs under old policy
"""
batch_size, G, action_dim = actions_group.shape
# Compute group-relative advantages (key GRPO idea)
mean_r = rewards_group.mean(dim= 1 , keepdim= True )
std_r = rewards_group.std(dim= 1 , keepdim= True ) + 1e-8
advantages = (rewards_group - mean_r) / std_r # (batch, G)
# Flatten for processing
states_exp = states.unsqueeze(1 ).expand(- 1 , G, - 1 ).reshape(- 1 , states.shape[- 1 ])
actions_flat = actions_group.reshape(- 1 , action_dim)
adv_flat = advantages.reshape(- 1 )
old_lp_flat = old_log_probs.reshape(- 1 )
# New policy log probs
concentrations = policy(states_exp)
dist = torch.distributions.Dirichlet(concentrations)
new_log_probs = dist.log_prob(actions_flat)
entropy = dist.entropy().mean()
# PPO-style clipped objective
ratio = torch.exp(new_log_probs - old_lp_flat)
surr1 = ratio * adv_flat
surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * adv_flat
actor_loss = - torch.min (surr1, surr2).mean()
# KL penalty (vs old policy)
kl_penalty = kl_coef * (old_lp_flat - new_log_probs).mean()
# Entropy bonus
loss = actor_loss + kl_penalty - entropy_coef * entropy
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(policy.parameters(), 0.5 )
optimizer.step()
return loss.item()
5. Boucle d’entraînement GRPO multi-seed
Chaque épisode : on échantillonne G trajectoires en parallèle, on calcule les avantages relatifs, on met à jour la policy.
def train_grpo_one_seed(returns_df, seed, device= "cpu" ):
"""Train GRPO for one seed."""
torch.manual_seed(seed)
np.random.seed(seed)
env = PortfolioEnv(returns_df, lookback= LOOKBACK, episode_len= EPISODE_LEN, fee_bps= FEE_BPS)
policy = GRPOPolicy(env.state_dim, env.action_dim, HIDDEN_DIM).to(device)
optimizer = optim.Adam(policy.parameters(), lr= LR)
losses = []
for ep in range (N_EPISODES):
# Collect G trajectories (group)
group_states = []
group_actions = []
group_rewards = []
group_log_probs = []
for g in range (GROUP_SIZE):
states_ep = []
actions_ep = []
rewards_ep = []
log_probs_ep = []
state = env.reset()
for step in range (env.episode_len):
s = torch.FloatTensor(state).unsqueeze(0 ).to(device)
with torch.no_grad():
conc = policy(s)
dist = torch.distributions.Dirichlet(conc)
weights = dist.sample()
log_prob = dist.log_prob(weights)
action_np = weights.squeeze(0 ).cpu().numpy()
next_state, reward, done, _ = env.step(action_np)
states_ep.append(state)
actions_ep.append(action_np)
rewards_ep.append(reward)
log_probs_ep.append(log_prob.item())
state = next_state
if done:
break
group_states.append(states_ep)
group_actions.append(actions_ep)
group_rewards.append(rewards_ep)
group_log_probs.append(log_probs_ep)
# Align episode lengths (use minimum)
min_len = min (len (r) for r in group_rewards)
# Build batch for GRPO update
n_steps = min_len
if n_steps < 10 :
continue
batch_states = []
batch_actions = []
batch_rewards = []
batch_old_lp = []
for t in range (0 , n_steps, max (1 , n_steps // 20 )): # subsample ~20 steps
states_t = np.array([gs[t] for gs in group_states])
actions_t = np.array([ga[t] for ga in group_actions])
rewards_t = np.array([gr[t] for gr in group_rewards])
lp_t = np.array([glp[t] for glp in group_log_probs])
batch_states.append(states_t) # (G, state_dim)
batch_actions.append(actions_t) # (G, action_dim)
batch_rewards.append(rewards_t) # (G,)
batch_old_lp.append(lp_t) # (G,)
if not batch_states:
continue
# Stack and reshape for GRPO update
all_states = torch.FloatTensor(np.concatenate(batch_states, axis= 0 )).to(device)
all_actions = torch.FloatTensor(np.concatenate(batch_actions, axis= 0 )).to(device)
all_rewards = torch.FloatTensor(np.concatenate(batch_rewards, axis= 0 )).to(device)
all_old_lp = torch.FloatTensor(np.concatenate(batch_old_lp, axis= 0 )).to(device)
# Simple batch update (no group structure needed for single-step advantages)
concentrations = policy(all_states)
dist = torch.distributions.Dirichlet(concentrations)
new_lp = dist.log_prob(all_actions)
# Group-relative advantage: normalize rewards across batch
adv = (all_rewards - all_rewards.mean()) / (all_rewards.std() + 1e-8 )
ratio = torch.exp(new_lp - all_old_lp)
surr1 = ratio * adv
surr2 = torch.clamp(ratio, 1 - CLIP_EPS, 1 + CLIP_EPS) * adv
actor_loss = - torch.min (surr1, surr2).mean()
kl_pen = KL_COEF * (all_old_lp - new_lp).mean()
entropy = dist.entropy().mean()
loss = actor_loss + kl_pen - ENTROPY_COEF * entropy
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(policy.parameters(), 0.5 )
optimizer.step()
losses.append(loss.item())
# OOS evaluation
oos_start = len (returns_df) - 500
oos_rets_port = []
oos_rets_bh = []
state = env.reset(start_idx= oos_start)
policy.eval ()
with torch.no_grad():
for t in range (min (500 , len (returns_df) - oos_start - 1 )):
s = torch.FloatTensor(state).unsqueeze(0 ).to(device)
conc = policy(s)
weights = conc / conc.sum () # deterministic: use normalized concentrations
action_np = weights.squeeze(0 ).cpu().numpy()
next_state, reward, done, info = env.step(action_np)
oos_rets_port.append(info["port_ret" ])
oos_rets_bh.append(info["bh_ret" ])
state = next_state
if done:
break
def sharpe(r):
return float (np.mean(r) / (np.std(r, ddof= 1 ) + 1e-8 ) * np.sqrt(252 ))
port_arr = np.array(oos_rets_port)
bh_arr = np.array(oos_rets_bh)
return {
"seed" : seed,
"sharpe_port" : sharpe(port_arr),
"sharpe_bh" : sharpe(bh_arr),
"delta_sharpe" : sharpe(port_arr) - sharpe(bh_arr),
"mean_ret_port" : float (np.mean(port_arr)) * 252 ,
"mean_ret_bh" : float (np.mean(bh_arr)) * 252 ,
"n_oos" : len (port_arr),
"final_loss" : losses[- 1 ] if losses else float ("nan" ),
"n_episodes" : len (losses)
}
# Run all seeds
device = "cuda" if torch.cuda.is_available() else "cpu"
results = []
for seed in SEEDS:
r = train_grpo_one_seed(returns, seed, device)
results.append(r)
print (f"Seed { seed} : Sharpe port= { r['sharpe_port' ]:.3f} , BH= { r['sharpe_bh' ]:.3f} , "
f"delta= { r['delta_sharpe' ]:+.3f} , episodes= { r['n_episodes' ]} " )
print (f" \n Done: { len (results)} seeds on { device} " )
Seed 0: Sharpe port=0.714, BH=0.732, delta=-0.018, episodes=120
Seed 1: Sharpe port=0.698, BH=0.732, delta=-0.034, episodes=120
Seed 7: Sharpe port=0.711, BH=0.732, delta=-0.021, episodes=120
Seed 42: Sharpe port=0.725, BH=0.732, delta=-0.008, episodes=120
Done: 4 seeds on cuda
6. Résultats et verdict GRPO
print ("=" * 70 )
print ("VERDICT -- QC-Py-RL-06: GRPO (Group Relative Policy Optimization)" )
print ("=" * 70 )
deltas = [r["delta_sharpe" ] for r in results]
mean_delta = np.mean(deltas)
std_delta = np.std(deltas, ddof= 1 ) if len (deltas) > 1 else float ("nan" )
sigma_edge = mean_delta / std_delta if std_delta > 1e-9 else float ("nan" )
n_positive = sum (1 for d in deltas if d > 0 )
mean_port = np.mean([r["sharpe_port" ] for r in results])
mean_bh = np.mean([r["sharpe_bh" ] for r in results])
print (f"GRPO Portfolio Sharpe (mean): { mean_port:+.3f} " )
print (f"Equal-Weight Sharpe: { mean_bh:.3f} " )
print (f"Delta mean: { mean_delta:+.3f} " )
print (f"Sigma edge: { sigma_edge:+.2f} " )
print (f"Seeds positive vs EW: { n_positive} / { len (results)} " )
print ()
if sigma_edge >= 2.0 and n_positive >= 3 :
verdict = "BEATS"
elif n_positive == 0 :
verdict = "NO BEATS"
else :
verdict = "INCONCLUSIVE"
print (f">>> VERDICT: { verdict} <<<" )
print ()
if verdict == "NO BEATS" :
print ("GRPO (sans critic, avantage relatif intra-groupe) ne depasse pas" )
print ("l'allocation equal-weight passive sur le panier anti-biais." )
elif verdict == "INCONCLUSIVE" :
print ("Signal partiel mais non robuste cross-seed." )
else :
print ("GRPO capture un signal exploitable — premier BEATS de la serie !" )
print ()
print ("=" * 70 )
print ("RL Series Progress (#1461)" )
print ("=" * 70 )
series = [
("RL-1. Q-Learning Tabulaire" , "NO BEATS" , "PR #1581" ),
("RL-2. PPO (numpy)" , "NO BEATS" , "PR #1583" ),
("RL-3. Reward Shaping (PyTorch)" , "NO BEATS" , "PR #1584" ),
("RL-4. Multi-Asset PPO" , "NO BEATS" , "PR #1585" ),
("RL-5. Tactical Overlay" , "NO BEATS" , "PR #1587" ),
("RL-6. GRPO (Group Relative)" , verdict, "This PR" ),
]
for name, v, pr in series:
print (f" { name:40s} | { v:15s} | { pr} " )
======================================================================
VERDICT -- QC-Py-RL-06: GRPO (Group Relative Policy Optimization)
======================================================================
GRPO Portfolio Sharpe (mean): +0.712
Equal-Weight Sharpe: 0.732
Delta mean: -0.020
Sigma edge: -1.86
Seeds positive vs EW: 0/4
>>> VERDICT: NO BEATS <<<
GRPO (sans critic, avantage relatif intra-groupe) ne depasse pas
l'allocation equal-weight passive sur le panier anti-biais.
======================================================================
RL Series Progress (#1461)
======================================================================
RL-1. Q-Learning Tabulaire | NO BEATS | PR #1581
RL-2. PPO (numpy) | NO BEATS | PR #1583
RL-3. Reward Shaping (PyTorch) | NO BEATS | PR #1584
RL-4. Multi-Asset PPO | NO BEATS | PR #1585
RL-5. Tactical Overlay | NO BEATS | PR #1587
RL-6. GRPO (Group Relative) | NO BEATS | This PR
Retour au sommet