RL-15 — GRPO (Group Relative Policy Optimization) sur CartPole-v1

# Parameters
EXECUTION_NOTEBOOK = "rl15-grpo-cartpole"

GRPO (Group Relative Policy Optimization, Shao et al. 2024, DeepSeekMath) calcule l’avantage d’une trajectoire relativement au groupe de K trajectoires, sans réseau de valeur. Ce notebook compare cette approche au PPO à avantage GAE sur CartPole-v1 : 20 itérations × 8 épisodes pour chaque algorithme, sur 6 graines appariées (0/1/7/42/99/123). Le verdict associe un écart standardisé, un test de Wilcoxon apparié et un IC95 % bootstrap.

Prérequis. rl_6c (PPO) et rl_6e (GRPO). Les deux politiques reprennent l’architecture 4-64-64-2 sur CartPole-v1.

Place dans l’arc. Après le GRPO from scratch, cette comparaison à budget d’épisodes égal cherche à savoir si le gain observé dans le post-entraînement des LLM se transpose à un environnement de contrôle classique.

Résultat de cette exécution GPU. PPO atteint 343,07 ± 49,88 de reward final moyen, contre 172,44 ± 49,50 pour GRPO. Les six différences appariées favorisent PPO ; l’écart GRPO − PPO est de −170,63 (edge −3,43σ), avec Wilcoxon bilatéral p = 0,0312 et IC95 % bootstrap [−237,07 ; −106,16]. Les trois critères du verdict sont réunis : PPO BEATS GRPO dans ce protocole, sans généralisation aux LLM. Les chiffres décrivent ce run et doivent être relus dans les sorties après toute ré-exécution.

Motivation

PPO utilise un avantage GAE fondé sur un réseau de valeur ; GRPO normalise les retours par groupe, (R − mean(R_group)) / std(R_group), sans critique dans la boucle d’entraînement comparée. L’absence de critique peut réduire le coût mémoire, mais ne prédit pas à elle seule la variance entre graines ni les rewards sur CartPole.

Hypothèse initiale : GRPO et PPO pourraient avoir des performances finales et une dispersion entre graines comparables. Cette exécution (n = 6) la contredit pour les performances finales : PPO = 343,07 ± 49,88, GRPO = 172,44 ± 49,50 ; l’écart moyen GRPO − PPO est −170,63 et son IC95 % bootstrap apparié est [−237,07 ; −106,16]. Les écarts-types sont en revanche du même ordre, avec GRPO très légèrement moins dispersé dans ce run (49,50 contre 49,88). Cette observation descriptive n’établit pas une différence de variance.

Le verdict directionnel est PPO BEATS GRPO : edge = −3,43σ, Wilcoxon bilatéral p = 0,0312 sur six différences non nulles, et IC95 % entièrement négatif. La conjonction porte seulement sur ce protocole court (K = 8, 20 × 8 épisodes, CartPole-v1). Le fait que GRPO évite un réseau de valeur ne garantit ni un meilleur reward ni une variance inter-graines plus faible.

1. Setup

Gymnasium et PyTorch, avec une graine par essai et torch.set_num_threads(1). La sélection du device accepte CUDA ou CPU pour l’entraînement, mais le notebook complet exige actuellement un GPU CUDA : la probe de la section 1.1 vérifie explicitement torch.cuda.is_available() et échouerait sur un hôte CPU-only. Cette exécution a utilisé CUDA.

Mémoire GPU. La section 1.1 enregistre une mesure de torch.cuda.max_memory_allocated() et la sortie de nvidia-smi sur le device employé. Elle prouve la borne pour sa charge synthétique de dix pas, pas pour l’entraînement CartPole complet.

import os
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")  # avant import torch pour le déterminisme cuBLAS
import random
import math
from dataclasses import dataclass, field
from typing import List, Tuple

import numpy as np
import torch
# La graine seule ne garantit pas le déterminisme des kernels CUDA.
torch.use_deterministic_algorithms(True, warn_only=True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
import torch.nn as nn
import torch.nn.functional as F
import gymnasium as gym

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
torch.set_num_threads(1)
print(f"Device: {DEVICE}")  # VRAM mesurée dans la cellule suivante
Device: cuda

Lecture du device. Device: cuda confirme que la sélection a trouvé un GPU pour ce run. La cellule de sélection pourrait afficher Device: cpu sur un autre hôte, mais la probe VRAM qui suit exige CUDA et arrêterait le notebook complet en CPU-only. Le témoin de cette exécution est donc le device effectivement utilisé par la probe et l’entraînement, non une preuve de compatibilité intégrale CPU.

1.1 VRAM probe — device CUDA reel

La cellule precedente declare le DEVICE sans le prouver ; ici on mesure torch.cuda.max_memory_allocated() apres une session GRPO reelle sur le GPU effectivement employe. Une borne memoire affirme sans mesure commitee ne vaut rien : la preuve decrit le device reellement employe, quel que soit l’hote.

Architecture testee : Policy 4-64-64-2 (~9K params) + Value 4-64-64-1 (~9K params), Adam, group_size=8, 10 GRPO steps sur batch_size=64. Cette mesure borne la complexite reelle du notebook.

Borne cible : peak VRAM < 6 GB (6144 MiB). Le device CUDA est identifie dynamiquement dans le verdict via torch.cuda.get_device_name(0) : aucun nom de GPU n’est grave en dur.

# VRAM probe - device CUDA reel identifie dynamiquement
import subprocess
import json as _json
import torch
import torch.nn as nn

_smi = subprocess.run(
    ["nvidia-smi", "--query-gpu=name,memory.total,memory.used,memory.free,driver_version",
     "--format=csv,noheader"],
    capture_output=True, text=True
)
print("[nvidia-smi]")
print(_smi.stdout.strip())
print()

assert torch.cuda.is_available(), "CUDA not available on this host"
torch.cuda.reset_peak_memory_stats()

class _ProbePolicy(nn.Module):
    def __init__(self, obs_dim=4, n_actions=2, hidden=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, hidden), nn.Tanh(),
            nn.Linear(hidden, hidden), nn.Tanh(),
            nn.Linear(hidden, n_actions),
        )
    def forward(self, x):
        return self.net(x)

class _ProbeValue(nn.Module):
    def __init__(self, obs_dim=4, hidden=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, hidden), nn.Tanh(),
            nn.Linear(hidden, hidden), nn.Tanh(),
            nn.Linear(hidden, 1),
        )
    def forward(self, x):
        return self.net(x).squeeze(-1)

torch.manual_seed(0)
policy = _ProbePolicy().to(DEVICE)
value_net = _ProbeValue().to(DEVICE)
n_params = sum(p.numel() for p in policy.parameters()) + sum(p.numel() for p in value_net.parameters())
print(f"[model] params={n_params} (target <50K), device={DEVICE}")

GROUP_SIZE = 8
T_MAX = 64
OBS_DIM = 4  # CartPole-v1 obs space ; self-contained (obs_dim etait un residu d etat interactif, NameError en run frais)
states = torch.randn(GROUP_SIZE, T_MAX, OBS_DIM, device=DEVICE)
actions = torch.randint(0, 2, (GROUP_SIZE, T_MAX), device=DEVICE)
logprobs_old = torch.randn(GROUP_SIZE, T_MAX, device=DEVICE)
rewards = torch.randn(GROUP_SIZE, device=DEVICE)
pad_mask = torch.ones(GROUP_SIZE, T_MAX, device=DEVICE)

opt_pi = torch.optim.Adam(policy.parameters(), lr=3e-4)
opt_v = torch.optim.Adam(value_net.parameters(), lr=1e-3)

def _probe_grpo_step():
    mean_r = rewards.mean()
    std_r = rewards.std() + 1e-8
    traj_advantages = (rewards - mean_r) / std_r
    advantages = traj_advantages.unsqueeze(1) * pad_mask
    valid = pad_mask.bool()
    logits = policy(states.reshape(-1, OBS_DIM))
    dist = torch.distributions.Categorical(logits=logits)
    logp_new = dist.log_prob(actions.reshape(-1))
    ratio = torch.exp(logp_new - logprobs_old.reshape(-1))
    adv_flat = advantages.reshape(-1)
    surr1 = ratio * adv_flat
    surr2 = torch.clamp(ratio, 0.8, 1.2) * adv_flat
    pi_loss = -torch.min(surr1, surr2)[valid.reshape(-1)].mean()
    opt_pi.zero_grad()
    pi_loss.backward()
    opt_pi.step()
    v = value_net(states.reshape(-1, OBS_DIM))
    returns = rewards.unsqueeze(1).expand_as(pad_mask).reshape(-1).detach()
    v_loss = ((v - returns[valid.reshape(-1)]) ** 2).mean()
    opt_v.zero_grad()
    v_loss.backward()
    opt_v.step()

for _ in range(10):
    _probe_grpo_step()

torch.cuda.synchronize()

peak_alloc_mib = torch.cuda.max_memory_allocated() / 1024**2
peak_reserved_mib = torch.cuda.max_memory_reserved() / 1024**2
final_alloc_mib = torch.cuda.memory_allocated() / 1024**2
gpu_total_mib = torch.cuda.get_device_properties(0).total_memory / 1024**2

print()
print(f"[VRAM peak after 10 GRPO steps]")
print(f"  peak allocated: {peak_alloc_mib:.2f} MiB")
print(f"  peak reserved:  {peak_reserved_mib:.2f} MiB")
print(f"  final allocated: {final_alloc_mib:.2f} MiB")
print(f"  GPU total:      {gpu_total_mib:.0f} MiB")

VRAM_BOUND_MIB = 6 * 1024
verdict_vram = peak_alloc_mib < VRAM_BOUND_MIB
print()
print(f"[verdict] peak VRAM = {peak_alloc_mib:.2f} MiB, borne < 6 GB ({VRAM_BOUND_MIB} MiB)")
gpu_name = torch.cuda.get_device_name(0)
print(f"[verdict] VRAM < 6 GB sur {gpu_name} ? {'YES - borne PROUVEE' if verdict_vram else 'NO'}")

_vram_result = {
    "device": str(DEVICE),
    "gpu_name": torch.cuda.get_device_name(0),
    "torch_version": torch.__version__,
    "cuda_available": torch.cuda.is_available(),
    "n_params": n_params,
    "vram_peak_mib": peak_alloc_mib,
    "vram_peak_reserved_mib": peak_reserved_mib,
    "vram_final_mib": final_alloc_mib,
    "gpu_total_mib": gpu_total_mib,
    "vram_bound_mib": VRAM_BOUND_MIB,
    "verdict_vram_under_6gb": verdict_vram,
    "nvidia_smi": _smi.stdout.strip(),
}
print()
print("[result] _vram_result =", _json.dumps(_vram_result, indent=2))
[nvidia-smi]
NVIDIA GeForce RTX 3080 Ti Laptop GPU, 16384 MiB, 0 MiB, 16173 MiB, 616.92

[model] params=9155 (target <50K), device=cuda

[VRAM peak after 10 GRPO steps]
  peak allocated: 65.67 MiB
  peak reserved:  66.00 MiB
  final allocated: 65.16 MiB
  GPU total:      16384 MiB

[verdict] peak VRAM = 65.67 MiB, borne < 6 GB (6144 MiB)
[verdict] VRAM < 6 GB sur NVIDIA GeForce RTX 3080 Ti Laptop GPU ? YES - borne PROUVEE

[result] _vram_result = {
  "device": "cuda",
  "gpu_name": "NVIDIA GeForce RTX 3080 Ti Laptop GPU",
  "torch_version": "2.11.0+cu128",
  "cuda_available": true,
  "n_params": 9155,
  "vram_peak_mib": 65.671875,
  "vram_peak_reserved_mib": 66.0,
  "vram_final_mib": 65.16455078125,
  "gpu_total_mib": 16383.5,
  "vram_bound_mib": 6144,
  "verdict_vram_under_6gb": true,
  "nvidia_smi": "NVIDIA GeForce RTX 3080 Ti Laptop GPU, 16384 MiB, 0 MiB, 16173 MiB, 616.92"
}

Lecture de la probe VRAM. La sortie [nvidia-smi] présente une RTX 3080 Ti Laptop (16 384 MiB), et gpu_name identifie ce même GPU comme device CUDA utilisé. La politique et le réseau de valeur de la probe totalisent 9 155 paramètres. Après dix pas sur les tenseurs synthétiques, le pic alloué est de 65,67 MiB, le pic réservé de 66,00 MiB et l’allocation finale de 65,16 MiB. Le pic alloué représente environ 1,07 % de la borne cible de 6 144 MiB : le verdict imprimé confirme cette borne pour la probe, et non pour l’entraînement complet. Les mesures et la version PyTorch (2.11.0+cu128) restent vérifiables dans _vram_result.

Verdict VRAM et portée. torch.cuda.max_memory_allocated() mesure 65,67 MiB sur le device CUDA identifié dans le JSON ; max_memory_reserved() indique 66,00 MiB. La borne de 6 144 MiB est vérifiée pour dix pas GRPO sur tenseurs synthétiques avec un batch de 64 et un groupe de huit. La marge arithmétique est d’environ 94× pour cette probe. Ni le pic des rollouts CartPole ni celui des 20 × 8 épisodes d’entraînement n’a été mesuré : on ne peut pas transformer cette marge en preuve du pic de l’entraînement complet.

@dataclass
class Config:
    env_name: str = "CartPole-v1"
    group_size: int = 8  # K = taille du groupe pour GRPO
    n_iterations: int = 20  # aligne sur SEEDS x 20 iterations x 8 envs
    n_envs_per_iter: int = 8
    lr_policy: float = 3e-4
    lr_value: float = 1e-3
    gamma: float = 0.99
    gae_lambda: float = 0.95  # PPO only
    clip_ratio: float = 0.2  # PPO only
    clip_ratio_grpo: float = 0.2  # GRPO reuse PPO-style clipping
    seed: int = 0

    @property
    def n_total_timesteps(self):
        return self.n_iterations * self.n_envs_per_iter * 500  # 500 max steps/episode


def make_env(seed):
    env = gym.make(Config.env_name)
    env.reset(seed=seed)
    return env


class PolicyNet(nn.Module):
    def __init__(self, obs_dim, n_actions):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, 64), nn.Tanh(),
            nn.Linear(64, 64), nn.Tanh(),
            nn.Linear(64, n_actions),
        )

    def forward(self, x):
        return self.net(x)

    def get_action(self, obs, deterministic=False):
        logits = self(obs)
        if deterministic:
            return logits.argmax(dim=-1)
        dist = torch.distributions.Categorical(logits=logits)
        action = dist.sample()
        log_prob = dist.log_prob(action)
        return action, log_prob


class ValueNet(nn.Module):  # used only by PPO
    def __init__(self, obs_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, 64), nn.Tanh(),
            nn.Linear(64, 64), nn.Tanh(),
            nn.Linear(64, 1),
        )

    def forward(self, x):
        return self.net(x).squeeze(-1)


env = make_env(Config.seed)
obs_dim = env.observation_space.shape[0]
n_actions = env.action_space.n
print(f"obs_dim={obs_dim}, n_actions={n_actions}")
obs_dim=4, n_actions=2

Lecture de la sortie — l’espace du probleme pose par deux entiers. obs_dim=4, n_actions=2 : CartPole-v1 en une ligne. Le reseau de politique decrit plus haut (4-64-64-2) se lit exactement sur ces deux nombres — 4 entrees d’observation, 2 logits de sortie (un par action), les deux couches cachees de 64 faisant tout le travail d’interpolation. C’est aussi ce qui borne la probe VRAM : un reseau de 9 155 parametres, la taille du probleme etant dans l’ordre de grandeur du CartPole classique, pas d’un LLM — la section suivante comparera PPO et GRPO sur CE terrain.

def rollout(env, policy, *, n_steps=500, deterministic=False):
    """Retourne `dones` (terminated flag) pour un GAE done-aware."""
    obs, _ = env.reset()
    obs_list, action_list, logprob_list, reward_list, done_list = [], [], [], [], []
    total_reward = 0.0
    for _ in range(n_steps):
        obs_t = torch.as_tensor(obs, dtype=torch.float32, device=DEVICE)
        with torch.no_grad():
            action, log_prob = policy.get_action(obs_t.unsqueeze(0), deterministic=deterministic)
        action = int(action.item())
        obs_list.append(obs)
        action_list.append(action)
        logprob_list.append(log_prob.item())
        obs, reward, terminated, truncated, _ = env.step(action)
        reward_list.append(reward)
        done_list.append(bool(terminated))  # truncated propagates via env auto-reset mais terminated = vrai done pour GAE
        total_reward += reward
        if terminated or truncated:
            break
    return (
        np.array(obs_list, dtype=np.float32),
        np.array(action_list, dtype=np.int64),
        np.array(logprob_list, dtype=np.float32),
        np.array(reward_list, dtype=np.float32),
        np.array(done_list, dtype=np.float32),  # NEW
        total_reward,
    )
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95):
    """GAE done-aware. `dones[t]=1` coupe le bootstrap (last_adv=0 au step suivant).

    Avant : le GAE concaténé traversait les frontières d'épisode → avantage spurieux.
    Maintenant : `last_adv = 0` immédiatement après un `done`. Cf préflight po-2025 commentaire 5459792430.
    """
    advantages = np.zeros_like(rewards, dtype=np.float32)
    last_adv = 0.0
    T = len(rewards)
    for t in reversed(range(T)):
        if t == T - 1:
            next_value = 0.0
        else:
            next_value = values[t + 1]
        delta = rewards[t] + gamma * next_value - values[t]
        # Bootstrap coupé si step précédent était terminal (ou si ce step est terminal — équivalence au sens où next_value=0 suffit)
        if dones[t]:
            last_adv = 0.0
        last_adv = delta + gamma * lam * last_adv
        advantages[t] = last_adv
    returns = advantages + values
    return advantages, returns
def ppo_update(policy, value_net, optimizer_p, optimizer_v, obs, actions, logprobs_old, advantages, returns, clip_ratio=0.2, n_epochs=4, batch_size=32):
    obs_t = torch.as_tensor(obs, dtype=torch.float32, device=DEVICE)
    actions_t = torch.as_tensor(actions, dtype=torch.long, device=DEVICE)
    logprobs_old_t = torch.as_tensor(logprobs_old, dtype=torch.float32, device=DEVICE)
    advantages_t = torch.as_tensor(advantages, dtype=torch.float32, device=DEVICE)
    returns_t = torch.as_tensor(returns, dtype=torch.float32, device=DEVICE)
    advantages_t = (advantages_t - advantages_t.mean()) / (advantages_t.std() + 1e-8)

    n = len(obs)
    idx = np.arange(n)
    for _ in range(n_epochs):
        np.random.shuffle(idx)
        for start in range(0, n, batch_size):
            mb = idx[start:start + batch_size]
            logits = policy(obs_t[mb])
            dist = torch.distributions.Categorical(logits=logits)
            logprobs_new = dist.log_prob(actions_t[mb])
            ratio = torch.exp(logprobs_new - logprobs_old_t[mb])
            surr1 = ratio * advantages_t[mb]
            surr2 = torch.clamp(ratio, 1 - clip_ratio, 1 + clip_ratio) * advantages_t[mb]
            policy_loss = -torch.min(surr1, surr2).mean()
            optimizer_p.zero_grad()
            policy_loss.backward()
            optimizer_p.step()

            value_pred = value_net(obs_t[mb])
            value_loss = F.mse_loss(value_pred, returns_t[mb])
            optimizer_v.zero_grad()
            value_loss.backward()
            optimizer_v.step()
def grpo_update(policy, optimizer_p, group_obs, group_actions, group_logprobs, group_rewards, pad_mask, clip_ratio=0.2, n_epochs=4, batch_size=32):
    """GRPO: avantage relatif au groupe, PAS de value network.

    `pad_mask` (K, T) marque les positions valides (1) vs padding (0).
    Avant : aplatissement `(K*T,)` sans masquer les positions padding → gradient spurieux sur les fantômes.
    Maintenant : `advantages = traj_advantages[:, None] * pad_mask` puis filtrage des positions valides avant flat.
    """
    K = group_obs.shape[0]
    T = group_obs.shape[1]

    # AVANTAGE GRPO = (R_trajectoire - mean(R_groupe)) / std(R_groupe)
    # C'est la DISCRIMINATION moteur : pas de GAE, pas de value net.
    group_mean = group_rewards.mean()
    group_std = group_rewards.std() + 1e-8
    traj_advantages = (group_rewards - group_mean) / group_std  # (K,)
    # mask les positions padding → 0 avantage sur fantômes
    advantages = (traj_advantages[:, None] * pad_mask).astype(np.float32)  # (K, T)

    # ne garder QUE les positions valides (pas d'aplatissement des fantômes)
    valid_mask = pad_mask.reshape(-1).astype(bool)  # (K*T,)
    obs_flat = group_obs.reshape(-1, group_obs.shape[-1])[valid_mask]
    actions_flat = group_actions.reshape(-1)[valid_mask]
    logprobs_old_flat = group_logprobs.reshape(-1)[valid_mask]
    advantages_flat = advantages.reshape(-1)[valid_mask]

    obs_t = torch.as_tensor(obs_flat, dtype=torch.float32, device=DEVICE)
    actions_t = torch.as_tensor(actions_flat, dtype=torch.long, device=DEVICE)
    logprobs_old_t = torch.as_tensor(logprobs_old_flat, dtype=torch.float32, device=DEVICE)
    advantages_t = torch.as_tensor(advantages_flat, dtype=torch.float32, device=DEVICE)

    n = len(obs_flat)
    idx = np.arange(n)
    for _ in range(n_epochs):
        np.random.shuffle(idx)
        for start in range(0, n, batch_size):
            mb = idx[start:start + batch_size]
            logits = policy(obs_t[mb])
            dist = torch.distributions.Categorical(logits=logits)
            logprobs_new = dist.log_prob(actions_t[mb])
            ratio = torch.exp(logprobs_new - logprobs_old_t[mb])
            surr1 = ratio * advantages_t[mb]
            surr2 = torch.clamp(ratio, 1 - clip_ratio, 1 + clip_ratio) * advantages_t[mb]
            policy_loss = -torch.min(surr1, surr2).mean()
            optimizer_p.zero_grad()
            policy_loss.backward()
            optimizer_p.step()
def train_ppo(seed, n_iterations=Config.n_iterations, n_envs_per_iter=Config.n_envs_per_iter):
    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    env = make_env(seed)
    policy = PolicyNet(obs_dim, n_actions).to(DEVICE)
    value_net = ValueNet(obs_dim).to(DEVICE)
    opt_p = torch.optim.Adam(policy.parameters(), lr=Config.lr_policy)
    opt_v = torch.optim.Adam(value_net.parameters(), lr=Config.lr_value)
    rewards_log = []
    for it in range(n_iterations):
        all_obs, all_actions, all_logprobs, all_rewards, all_values, all_dones = [], [], [], [], [], []
        for _ in range(n_envs_per_iter):
            obs, actions, logprobs, rewards, dones, total_r = rollout(env, policy, deterministic=False)
            all_obs.append(obs); all_actions.append(actions); all_logprobs.append(logprobs)
            all_rewards.append(rewards); all_dones.append(dones)
            with torch.no_grad():
                v = value_net(torch.as_tensor(obs, dtype=torch.float32, device=DEVICE)).cpu().numpy()
            all_values.append(v)
            rewards_log.append(total_r)

        # GAE PAR TRAJECTOIRE (done-aware), puis concaténation des résultats
        all_advantages, all_returns = [], []
        for rewards_traj, values_traj, dones_traj in zip(all_rewards, all_values, all_dones):
            adv, ret = compute_gae(rewards_traj, values_traj, dones_traj, gamma=Config.gamma, lam=Config.gae_lambda)
            all_advantages.append(adv)
            all_returns.append(ret)

        obs_cat = np.concatenate(all_obs)
        actions_cat = np.concatenate(all_actions)
        logprobs_cat = np.concatenate(all_logprobs)
        advantages_cat = np.concatenate(all_advantages)
        returns_cat = np.concatenate(all_returns)
        ppo_update(policy, value_net, opt_p, opt_v, obs_cat, actions_cat, logprobs_cat, advantages_cat, returns_cat, clip_ratio=Config.clip_ratio)
    return rewards_log, policy
def train_grpo(seed, n_iterations=Config.n_iterations, group_size=Config.group_size):
    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    env = make_env(seed)
    policy = PolicyNet(obs_dim, n_actions).to(DEVICE)
    opt_p = torch.optim.Adam(policy.parameters(), lr=Config.lr_policy)
    rewards_log = []
    for it in range(n_iterations):
        group_obs, group_actions, group_logprobs, group_rewards = [], [], [], []
        for _ in range(group_size):
            obs, actions, logprobs, rewards, dones, total_r = rollout(env, policy, deterministic=False)
            group_obs.append(obs); group_actions.append(actions); group_logprobs.append(logprobs)
            group_rewards.append(total_r)
            rewards_log.append(total_r)
        T_max = max(len(o) for o in group_obs)
        obs_dim_ = obs_dim
        padded_obs = np.zeros((group_size, T_max, obs_dim_), dtype=np.float32)
        padded_actions = np.zeros((group_size, T_max), dtype=np.int64)
        padded_logprobs = np.zeros((group_size, T_max), dtype=np.float32)
        pad_mask = np.zeros((group_size, T_max), dtype=np.float32)  # mask explicite
        for k in range(group_size):
            T_k = len(group_obs[k])
            padded_obs[k, :T_k] = group_obs[k]
            padded_actions[k, :T_k] = group_actions[k]
            padded_logprobs[k, :T_k] = group_logprobs[k]
            pad_mask[k, :T_k] = 1.0  # 1 sur les positions valides
        group_rewards = np.array(group_rewards, dtype=np.float32)
        grpo_update(policy, opt_p, padded_obs, padded_actions, padded_logprobs, group_rewards, pad_mask, clip_ratio=Config.clip_ratio_grpo)
    return rewards_log, policy

2. Multi-seed comparison PPO vs GRPO

6 seeds (0/1/7/42/99/123), seed deterministe par trial. Un multi-seed >= 6 est necessaire pour deux raisons conjointes : tout claim « improvement » exige plusieurs graines (cf pr-review-discipline C), et le test de Wilcoxon exact bilateral n’atteint p < 0.05 qu’a partir de n=6 (n=4 : min p=0.125 ; n=6 : min p=2/64=0.03125).

Parametres executes :

  • N_ITERATIONS = 20 (20 iterations par seed)
  • N_ENVS_PER_ITER = 8 (PPO : 8 episodes par iter)
  • GROUP_SIZE = 8 (GRPO : K=8 trajectoires par groupe)
  • SEEDS = [0, 1, 7, 42, 99, 123] (6 seeds)

Metrique : mean(rewards[-30:]) (reward moyen sur les 30 dernieres iterations x n_envs_per_iter episodes) — la final performance. Aussi std inter-seed = stabilite.

Pourquoi 6 seeds et pas 4 : avec n=4, le gate Wilcoxon p < 0.05 est structurellement inatteignable (le minimum possible sur les 16 configurations de signes est 0.125). Porter l’effectif a 6 seeds rend le gate atteignable (min p=0.03125 sur 64 configurations) — sans cela, le verdict INCONCLUSIVE serait garanti d’avance, quelle que soit la realite mesuree.

SEEDS = [0, 1, 7, 42, 99, 123]  # 6 seeds — Wilcoxon n=6 atteint min p=0.03125 (<0.05 gate atteignable, vs n=4 min p=0.125 toujours)
N_ITERATIONS = 20
N_ENVS_PER_ITER = 8
GROUP_SIZE = 8

ppo_runs = []
grpo_runs = []
for seed in SEEDS:
    ppo_rewards, _ = train_ppo(seed, n_iterations=N_ITERATIONS, n_envs_per_iter=N_ENVS_PER_ITER)
    grpo_rewards, _ = train_grpo(seed, n_iterations=N_ITERATIONS, group_size=GROUP_SIZE)
    ppo_runs.append(ppo_rewards)
    grpo_runs.append(grpo_rewards)
    print(f"seed={seed}: PPO final30 mean={np.mean(ppo_rewards[-30:]):.2f}, GRPO final30 mean={np.mean(grpo_rewards[-30:]):.2f}")
seed=0: PPO final30 mean=429.37, GRPO final30 mean=129.73
seed=1: PPO final30 mean=280.07, GRPO final30 mean=188.27
seed=7: PPO final30 mean=303.27, GRPO final30 mean=199.20
seed=42: PPO final30 mean=356.90, GRPO final30 mean=234.30
seed=99: PPO final30 mean=314.97, GRPO final30 mean=197.13
seed=123: PPO final30 mean=373.83, GRPO final30 mean=86.00

Lecture par graine — avant agrégation. Les rewards moyens sur les 30 derniers épisodes sont, dans l’ordre PPO / GRPO : graine 0, 429,37 / 129,73 ; 1, 280,07 / 188,27 ; 7, 303,27 / 199,20 ; 42, 356,90 / 234,30 ; 99, 314,97 / 197,13 ; 123, 373,83 / 86,00. PPO mène dans 6/6 paires. Les écarts appariés se calculent directement à partir de ces lignes ; le meilleur GRPO (234,30, graine 42) reste sous le plus faible PPO (280,07, graine 1) dans cet échantillon. Ces comparaisons ne présument pas de la distribution hors des six graines testées.

ppo_final = np.array([np.mean(r[-30:]) for r in ppo_runs])
grpo_final = np.array([np.mean(r[-30:]) for r in grpo_runs])

print(f"PPO  : mean={ppo_final.mean():.2f}, std={ppo_final.std():.2f}, seeds={SEEDS}")
print(f"GRPO : mean={grpo_final.mean():.2f}, std={grpo_final.std():.2f}, seeds={SEEDS}")

delta = grpo_final.mean() - ppo_final.mean()
sigma = (grpo_final.std() + ppo_final.std()) / 2
edge_sigma = delta / max(sigma, 1.0)
print(f"GRPO - PPO delta = {delta:.2f}, edge (naive) = {edge_sigma:.2f}sigma")

# Wilcoxon signed-rank test apparié
# 6 seeds (n=6) — Wilcoxon exact bilatéral min p=2/64=0.03125 (<0.05 gate atteignable)
# Vérification ties (= 0 différence) — Wilcoxon scipy utilise approximation avec ties
from scipy.stats import wilcoxon
diffs = grpo_final - ppo_final
n_ties = int((diffs == 0).sum())
if n_ties > 0:
    print(f"WARN: {n_ties}/{len(diffs)} paires avec diff=0 (ties) — Wilcoxon scipy utilise approximation, p-value peut être inexacte")
stat, p_wilcoxon = wilcoxon(diffs)  # two-sided, exact si n<=50 sans ties
print(f"Wilcoxon signed-rank (n={len(diffs)}, ties={n_ties}): stat={stat}, p-value={p_wilcoxon:.4f}")

# IC95% via bootstrap percentile (10000 resamples)
rng = np.random.default_rng(42)
n_boot = 10000
boot_deltas = np.array([rng.choice(diffs, size=len(diffs), replace=True).mean() for _ in range(n_boot)])
ci_low, ci_high = np.percentile(boot_deltas, [2.5, 97.5])
print(f"IC95% delta (bootstrap): [{ci_low:.2f}, {ci_high:.2f}]")
PPO  : mean=343.07, std=49.88, seeds=[0, 1, 7, 42, 99, 123]
GRPO : mean=172.44, std=49.50, seeds=[0, 1, 7, 42, 99, 123]
GRPO - PPO delta = -170.63, edge (naive) = -3.43sigma
Wilcoxon signed-rank (n=6, ties=0): stat=0.0, p-value=0.0312
IC95% delta (bootstrap): [-237.07, -106.16]

Lecture des agrégats. La cellule affiche PPO 343,07 ± 49,88 et GRPO 172,44 ± 49,50 sur six graines. L’écart des moyennes GRPO − PPO est −170,63 ; la mesure edge (naive) est −3,43σ. Le code utilise pour dénominateur la moyenne des deux écarts-types entre graines : cette mesure descriptive n’est pas une statistique de test supplémentaire. Les six différences appariées sont négatives, d’où Wilcoxon signed-rank (n=6, ties=0): stat=0.0, p-value=0.0312. L’IC95 % bootstrap des différences appariées, [−237,07 ; −106,16], exclut zéro du côté PPO. Les moyennes affichées sont arrondies ; le delta est calculé sur les valeurs non arrondies.

# verdict tri-state SYMETRISE (delta < 0 et > 0 tous deux traités)
# Branche PPO BEATS GRPO etait inatteignable en v2 car elif edge_sigma <= 2.0 capturait tous les negatifs.
# v3 :
#   si edge > 2 AND p < 0.05 AND IC du bon cote -> GRPO BEATS PPO
#   si edge < -2 AND p < 0.05 AND IC du bon cote -> PPO BEATS GRPO
#   sinon INCONCLUSIVE (toutes les autres combinaisons)
if edge_sigma > 2.0 and p_wilcoxon < 0.05 and ci_low > 0:
    verdict = "GRPO BEATS PPO (edge>=2sigma AND Wilcoxon p<0.05 AND IC95% excludes 0)"
elif edge_sigma < -2.0 and p_wilcoxon < 0.05 and ci_high < 0:
    verdict = "PPO BEATS GRPO (edge<=-2sigma AND Wilcoxon p<0.05 AND IC95% excludes 0)"
else:
    verdict = "INCONCLUSIVE (edge |sigma|<2 OR p>=0.05 OR IC includes 0)"
print(f"VERDICT : {verdict}")
VERDICT : PPO BEATS GRPO (edge<=-2sigma AND Wilcoxon p<0.05 AND IC95% excludes 0)

Lecture du verdict. Le code imprime PPO BEATS GRPO : edge −3,43σ (sous −2σ), Wilcoxon bilatéral p = 0,0312 (< 0,05), IC95 % bootstrap [−237,07 ; −106,16] (borne haute négative). Les trois conditions directionnelles sont réunies par cette exécution. Cette lecture remplace les chiffres d’un run antérieur ; les sorties enregistrées des cellules de mesure et ce texte décrivent désormais le même run GPU.

3. Lecture du résultat

Un verdict directionnel exige conjointement : (1) |edge| ≥ 2σ entre graines, (2) Wilcoxon apparié p < 0,05 et (3) IC95 % bootstrap des différences appariées entièrement du bon côté de zéro. Si une condition manque, le code rend INCONCLUSIVE.

La sortie de ce run donne −3,43σ, p = 0,0312 (six paires sans égalité) et [−237,07 ; −106,16] : le verdict est PPO BEATS GRPO dans ce régime. Les dispersions affichées sont proches : std PPO 49,88, std GRPO 49,50. La très faible différence descriptive ne démontre pas une réduction de variance par GRPO, et ne contribue pas au critère de verdict.

Limites. Avec six paires toutes du même signe, le minimum bilatéral de Wilcoxon est 0,03125 ; le test serait incapable d’atteindre p < 0,05 avec seulement quatre paires. CartPole-v1 et 20 itérations × 8 épisodes ne représentent pas un post-entraînement de LLM. Enfin, la probe CUDA mesure la mémoire allouée après dix pas synthétiques et vérifie la borne affichée dans sa propre sortie ; elle ne mesure pas le pic mémoire de l’entraînement CartPole complet. Les graines, l’environnement logiciel et les kernels GPU peuvent affecter les rewards d’un autre run : toute nouvelle exécution impose une relecture des chiffres et du verdict.

4. Ce que ce notebook établit — et ce qu’il ne dit pas

Preuve enregistrée. Toutes les cellules code ont été exécutées de bout en bout sans erreur ; les six graines appariées, les rewards par graine, le Wilcoxon, l’IC95 % bootstrap et le verdict figurent dans les sorties. La cellule de probe enregistre le GPU utilisé et son pic d’allocation. Le GAE de PPO tient compte des fins d’épisode et la mise à jour GRPO masque les positions de remplissage ; ces propriétés se vérifient dans le code, non par les seules métriques finales.

Portée du résultat. PPO mène sur 6/6 graines de CartPole-v1 (groupe K = 8, 20 × 8 épisodes) et satisfait les trois conditions directionnelles : edge −3,43σ, p = 0,0312, IC95 % [−237,07 ; −106,16]. Cela n’établit aucune supériorité universelle de PPO ni de conclusion sur les LLM. La borne GPU < 6 GB est vérifiée pour la probe synthétique, pas pour l’ensemble des rollouts et mises à jour du notebook ; sa valeur mesurée figure dans la sortie de cette probe.

Retour au sommet