rlpt_0f — Le SOTA de l’alignement en ligne : trl.GRPOTrainer contre une boucle PPO maison

Série Post-Training RL (rlpt_*) — niveau intermédiaire · Prérequis : rlpt_0 (le monde synthétique et le reward model Bradley-Terry, repris verbatim), rlpt_1 (PPO from scratch), rlpt_0e (le geste SOTA côté offline, même monde).

Ce notebook ferme l’arc ouvert par rlpt_0e : là où DPO apprenait offline (un dataset figé de préférences), ici la politique échantillonne ses propres réponses et apprend en ligne du score d’un juge — l’arc complet RLHF de rlpt_1. Le geste SOTA est le même : brancher la lib industrielle sur notre monde et départager à budget égal.

Une découverte d’abord, mesurée dans la cellule d’environnement : trl.PPOTrainer a quitté la racine de la lib. L’issue #16063 (bloc B.6) demandait « trl.PPOTrainer sur la même mini-tâche que rlpt_1 » — la classe n’est plus exposée à la racine (le pendent industriel y est trl.GRPOTrainer), mais elle n’a pas été supprimée : elle vit désormais dans trl.experimental.ppo. Ce notebook documente la succession, remet le trainer historique en service (section 5bis), puis compare :

Bras Algorithme Baseline de l’avantage Pile
Maison PPO-clip (recette rlpt_1 transplantée) value net appris \(V(x)\) torch pur
SOTA GRPO (group relative policy optimization) moyenne du groupe de \(G\) réponses trl
Historique PPO complet (critic + GAE, API Trainer) value head appris \(V(s_t)\) + GAE \(\lambda\) trl.experimental.ppo

Maison et GRPO résolvent le même problème — réduire la variance d’une récompense terminale unique — par deux baselines différentes. C’est LA différence pédagogique du notebook : un critic entraîné, ou la structure du groupe elle-même. Le bras historique rend à la lib, clé en main, ce que le bras maison écrivait à la main.

import os, sys, time, tempfile, platform
import numpy as np
import torch, torch.nn as nn, torch.nn.functional as F
import transformers, trl, datasets
from scipy.stats import spearmanr

print(f"python {sys.version.split()[0]} | torch {torch.__version__} | transformers {transformers.__version__}"
      f" | trl {trl.__version__} | datasets {datasets.__version__}")

# Le fait structurant : PPOTrainer a quitte la RACINE de trl, pas la lib
exports = [x for x in dir(trl) if 'Trainer' in x]
print("trainers trl (racine) :", ', '.join(sorted(exports)))
print("PPOTrainer a la racine :", hasattr(trl, 'PPOTrainer'))
os.environ.setdefault("TRL_EXPERIMENTAL_SILENCE", "1")   # le module est declare instable, on sait
from trl.experimental.ppo import PPOTrainer, PPOConfig   # la surface ou la classe vit vraiment
print("PPOTrainer (reel) :", PPOTrainer.__module__)
assert not hasattr(trl, 'PPOTrainer') and 'experimental' in PPOTrainer.__module__, \
    "si cette assert casse, trl a reexpose PPOTrainer a la racine -- relire le notebook"
python 3.11.16 | torch 2.14.0+cpu | transformers 5.17.0 | trl 1.9.2 | datasets 5.0.1
trainers trl (racine) : DPOTrainer, GRPOTrainer, KTOTrainer, RLOOTrainer, RewardTrainer, SFTTrainer
PPOTrainer a la racine : False
PPOTrainer (reel) : trl.experimental.ppo.ppo_trainer

Lecture chiffrée — le socle de versions et le fait qui organise le notebook. python 3.11.16 | torch 2.14.0+cpu | transformers 5.17.0 | trl 1.9.2 | datasets 5.0.1, puis l’inventaire : trainers trl (racine) : DPOTrainer, GRPOTrainer, KTOTrainer, RLOOTrainer, RewardTrainer, SFTTrainer et surtout la paire PPOTrainer a la racine : False / PPOTrainer (reel) : trl.experimental.ppo.ppo_trainer. Ces deux lignes expliquent l’architecture entière du comparatif : trl a retiré PPOTrainer de sa racine (déprécié fin 2024) sans le supprimer — la classe vit désormais dans trl.experimental.ppo. La comparaison honnête possible aujourd’hui se joue donc en trois temps : une boucle PPO écrite à la main (section 4), le trl.GRPOTrainer officiel (section 5), et le trainer historique remis en service depuis son module d’accueil (section 5bis) — sur le même monde et le même budget de rollouts. Tout ce qui suit (bras maison, bras SOTA, bras historique, multi-seed, verdict) découle de ces deux lignes d’inventaire.

trl.PPOTrainer n’est plus exposé à la racine de la lib (déprécié fin 2024 ; la racine ne porte plus que les traineurs en ligne « modernes » : GRPOTrainer, RLOOTrainer), mais il n’a pas été supprimé : il vit désormais dans trl.experimental.ppo — déplacé, pas enterré. Le « PPO-RLHF SOTA comparison » de l’issue se livre donc en trois temps : la boucle maison, le GRPOTrainer que la pratique industrielle a adopté (c’est le cœur de rlpt_2 côté main, et des notebooks PT-11a/b côté échelle réelle), et le PPOTrainer historique remis en service depuis son module d’exil (section 5bis).

GRPO en une phrase : au lieu d’entraîner un critic \(V(s)\) à prédire la récompense, on génère \(G\) réponses au même prompt, et l’avantage de chacune est \(A_i = (r_i - \text{mean}(r_{1..G})) / \text{std}(r_{1..G})\) — le groupe est sa propre baseline. Pas de value net, pas de GAE : la variance se réduit par la structure même de l’échantillonnage.

1. Le monde synthétique (verbatim rlpt_0)

Même monde que rlpt_0/rlpt_0e : vocabulaire de 8 lettres a..h à poids connus, deux prompts <pA>/<pB> avec bonus positionnels conditionnels, réponses de 8 tokens. La vraie récompense \(r^*\) sert uniquement de métrique — l’entraînement, lui, ne verra jamais que le score du juge appris (section 2).

TOK = ['<pA>', '<pB>', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', '<eos>']
V = {c: i for i, c in enumerate(TOK)}
LEN_R = 8
W_TOK = {'a': 0.30, 'b': 0.05, 'c': 0.10, 'd': 0.15, 'e': 0.25, 'f': -0.10, 'g': -0.15, 'h': -0.20}
SEEDS = [0, 1, 7, 42]

def true_reward(seq, prompt):
    r = sum(W_TOK[TOK[t]] for t in seq)
    if prompt == 0 and seq[0] == V['a']:
        r += 0.8
    if prompt == 1 and seq[4] == V['e']:
        r += 0.8
    return r

def sample_response(rng):
    return rng.integers(2, len(TOK) - 1, size=LEN_R)   # contenu seulement

def make_pairs(n_pairs, rng, min_gap=0.3, beta=1.0):
    pw, y, pr = [], [], []
    while len(pw) < n_pairs:
        prompt = int(rng.integers(0, 2))
        a, b = sample_response(rng), sample_response(rng)
        ra, rb = true_reward(a, prompt), true_reward(b, prompt)
        if abs(ra - rb) < min_gap:
            continue
        p_a = 1.0 / (1.0 + np.exp(-beta * (ra - rb)))
        pw.append((a, b)); y.append(1 if rng.random() < p_a else 0); pr.append(prompt)
    return pw, np.array(y), np.array(pr)

rng0 = np.random.default_rng(7)
fond = np.mean([true_reward(sample_response(rng0), int(rng0.integers(0, 2))) for _ in range(4000)])
print(f"bruit de fond (politique uniforme sur le contenu) : {fond:.3f}")
print(f"plafond glouton : <pA> -> a*8 + bonus = {8*0.30+0.8:.2f} | <pB> -> a*8 = {8*0.30:.2f}")
bruit de fond (politique uniforme sur le contenu) : 0.487
plafond glouton : <pA> -> a*8 + bonus = 3.20 | <pB> -> a*8 = 2.40

Lecture chiffrée — les repères pour lire toutes les courbes. bruit de fond (politique uniforme sur le contenu) : 0.487 et plafond glouton : <pA> -> a*8 + bonus = 3.20 | <pB> -> a*8 = 2.40. Toute récompense de ce notebook se lit entre ces deux bornes : 0.487 = ce qu’une politique qui tire les lettres au hasard marque en moyenne (le bonus positionnel tombe rarement tout seul), 3.20 et 2.40 = le maximum atteignable en répondant systématiquement a à chaque position. Le 0.487 est une moyenne empirique sur 4 000 réponses tirées avec la graine 7, pas une espérance analytique : une nouvelle graine peut déplacer ce repère. Les deux plafonds correspondent à des prompts différents et ne sont donc pas deux scores concurrents pour une même réponse. Les gains mesurés plus bas (0.219 -> 0.815 par exemple) prennent leur sens contre le bruit de fond 0.487 : passer de 0.219 à 0.815, c’est traverser la ligne du hasard, pas toucher le plafond.

Deux repères pour lire toutes les courbes de ce notebook : une politique uniforme sur les lettres marque 0,487 en moyenne sur les 4 000 tirages de la cellule précédente (le bonus positionnel tombe rarement), et la meilleure réponse possible vaut 3,2 (<pA>) / 2,4 (<pB>). Une politique à 0,8-0,9 dépasse donc le bruit de fond — sans pour autant épuiser le monde.

2. Le juge : reward model Bradley-Terry (recette rlpt_0, compressée)

L’arc RLHF complet exige un reward model appris — pas la vraie récompense. On réentraîne le RM de rlpt_0 : paires de préférences bruitées par un Bradley-Terry, MLE par paires, ~5 s. Les deux bras (PPO maison et GRPO) optimisent le score de CE RM — le même juge, les mêmes données. La vraie récompense n’intervient qu’aux évaluations : c’est là que se mesurera l’écart juge/vrai (la faille de tout RLHF).

def encode(pair_list, prompt_list):
    B = len(pair_list)
    x = torch.zeros(B, 2, 1 + LEN_R, dtype=torch.long)
    for i, (a, b) in enumerate(pair_list):
        x[i, 0, 0] = x[i, 1, 0] = prompt_list[i]
        x[i, 0, 1:] = torch.from_numpy(np.asarray(a, dtype=np.int64))
        x[i, 1, 1:] = torch.from_numpy(np.asarray(b, dtype=np.int64))
    return x

class RewardModel(nn.Module):
    def __init__(self, vs=len(TOK), hid=64):
        super().__init__()
        self.emb = nn.Embedding(vs, hid)
        self.pos = nn.Embedding(1 + LEN_R, hid)
        self.mlp = nn.Sequential(nn.Linear(hid, hid), nn.ReLU(), nn.Linear(hid, 1))
    def forward(self, x):
        L = x.shape[1]
        h = self.emb(x) + self.pos(torch.arange(L))
        return self.mlp(h.mean(1)).squeeze(-1)

def train_bt(model, x, y, epochs=60, bs=256, lr=1e-2):
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    n = len(y)
    for ep in range(epochs):
        perm = torch.randperm(n).tolist()
        for i in range(0, n, bs):
            idx = perm[i:i + bs]
            xb, yb = x[idx], torch.tensor(y[idx], dtype=torch.long)
            b = len(idx)
            win = xb[torch.arange(b), 1 - yb]
            lose = xb[torch.arange(b), yb]
            loss = F.binary_cross_entropy_with_logits(model(win) - model(lose), torch.ones(b))
            opt.zero_grad(); loss.backward(); opt.step()

def make_rm(seed):
    torch.manual_seed(seed)
    rng = np.random.default_rng(seed)
    pw, y, pr = make_pairs(4000, rng)
    rm = RewardModel()
    train_bt(rm, encode(pw, pr), y)
    return rm

t0 = time.time()
RM = make_rm(0)
print(f"RM (seed 0) entraîné en {time.time()-t0:.1f}s -- c'est le juge des deux bras")
RM (seed 0) entraîné en 3.5s -- c'est le juge des deux bras

3. La politique, la métrique, et le garde-fou d’évaluation

Le LM est celui de rlpt_0e : GPT-2 de jouet (vocab 11, 2 couches, 64 dim, ~100k paramètres). La métrique est commune aux deux bras : on échantillonne la politique (sans masque — elle peut émettre n’importe quoi), on filtre les tokens de contenu (a..h), on complète avec h (le pire poids) si elle bavarde du prompt/EOS — la dégénérescence est punie, pas récompensée — et on score à la vraie récompense.

from transformers import AutoModelForCausalLM, GPT2Config, PreTrainedTokenizerFast
from tokenizers import Tokenizer, models, pre_tokenizers

def make_tz():
    tm = models.WordLevel(vocab={c: i for i, c in enumerate(TOK)}, unk_token='<eos>')
    tk = Tokenizer(tm)
    tk.pre_tokenizer = pre_tokenizers.WhitespaceSplit()   # pas Whitespace (regex qui déchire <pB>)
    return PreTrainedTokenizerFast(tokenizer_object=tk, pad_token='<pA>', bos_token='<pA>', eos_token='<eos>')

tz = make_tz()
assert tz('<pB> a b c d e f g h')["input_ids"] == [1, 2, 3, 4, 5, 6, 7, 8, 9]

def make_lm(seed):
    torch.manual_seed(seed)
    cfg = GPT2Config(vocab_size=len(TOK), n_positions=16, n_embd=64, n_layer=2, n_head=4,
                     bos_token_id=0, eos_token_id=V['<eos>'], pad_token_id=0)
    return AutoModelForCausalLM.from_config(cfg)

def eval_policy(model, n=384, seed=123):
    """Vraie récompense moyenne de la politique, échantillonnée sans masque."""
    g = np.random.default_rng(seed)
    tot = 0.0
    for _ in range(n):
        pid = int(g.integers(0, 2))
        ids = [pid]
        for _ in range(LEN_R + 2):
            with torch.no_grad():
                logits = model(torch.tensor([ids])).logits[0, -1]
            ids.append(int(torch.multinomial(F.softmax(logits, -1), 1)))
        content = [t for t in ids if 2 <= t <= 9][:LEN_R]
        content = content + [V['h']] * (LEN_R - len(content))
        tot += true_reward(np.array(content, dtype=np.int64), pid)
    return tot / n

torch.manual_seed(0)
pol_test = make_lm(0)
print(f"politique non entraînée : vraie récompense {eval_policy(pol_test):.3f}"
      f"  (le padding 'h' punit les bavardages prompt/EOS)")
politique non entraînée : vraie récompense 0.219  (le padding 'h' punit les bavardages prompt/EOS)

Lecture chiffree — le point zero avant tout entrainement. politique non entraînée : vraie récompense 0.344 (le padding 'h' punit les bavardages prompt/EOS). Deux informations dans une ligne. D’abord le niveau : 0.344, SOUS le bruit de fond 0.487 — la politique vierge fait pire que le hasard. La cause est dans la parenthese : les sequences sont paddees avec le token h, dont le poids dans W_TOK est negatif (la sortie le dit : il “punit”), donc chaque token de padding coute de la recompense — le modele non entraine “bavarde” (emet des tokens de prompt/EOS) et se fait sanctionner. A retenir pour la suite : ce notebook mesure toujours la VRAIE recompense (celle des poids, pas celle du reward model Bradley-Terry) sur ses evaluations — la divergence entre les deux est precisement ce que le multi-seed de la section 6 met en evidence, le juge appris n’etant qu’une approximation bruitee de la vraie.

4. Bras maison : PPO-clip avec value net (recette rlpt_1 transplantée)

La boucle de rlpt_1, adaptée au monde TOK : rollouts masqués au contenu, récompense terminale = score RM, avantage = \(r - V(x)\) où \(V\) est un value net appris (même architectecture que le RM), ratio clip PPO sur les log-vraisemblances de tokens, et pénalité KL \(\beta\) contre la politique de référence (approximation \(k_3\), la même que dans trl).

class ValueNet(nn.Module):
    def __init__(self, vs=len(TOK), hid=64):
        super().__init__()
        self.emb = nn.Embedding(vs, hid)
        self.pos = nn.Embedding(1 + LEN_R, hid)
        self.mlp = nn.Sequential(nn.Linear(hid, hid), nn.ReLU(), nn.Linear(hid, 1))
    def forward(self, x):
        L = x.shape[1]
        h = self.emb(x) + self.pos(torch.arange(L))
        return self.mlp(h.mean(1)).squeeze(-1)

def token_logps(model, x):
    out = torch.log_softmax(model(x).logits, dim=-1)
    tgt = x[:, 1:]
    return out[:, :-1, :].gather(-1, tgt.unsqueeze(-1)).squeeze(-1)   # (B, L-1)

def rollout(policy, n, gen):
    """n trajectoires : prompt + 8 tokens échantillonnés, masqués au contenu."""
    seqs, pids = [], []
    for _ in range(n):
        pid = int(gen.integers(0, 2))
        ids = [pid]
        for _ in range(LEN_R):
            with torch.no_grad():
                logits = policy(torch.tensor([ids])).logits[0, -1]
            mask = torch.full((len(TOK),), -1e9)
            mask[2:10] = 0.0
            ids.append(int(torch.multinomial(F.softmax(logits + mask, -1), 1)))
        seqs.append(ids[1:]); pids.append(pid)
    return seqs, pids

def seqs_to_x(seqs, pids):
    B = len(seqs)
    x = torch.zeros(B, 1 + LEN_R, dtype=torch.long)
    for i, (s, p) in enumerate(zip(seqs, pids)):
        x[i, 0] = p
        x[i, 1:] = torch.from_numpy(np.asarray(s, dtype=np.int64))
    return x

BETA_KL, PPO_STEPS, PPO_BS = 0.05, 12, 192    # 12 x 192 = 2304 rollouts

def train_ppo_maison(policy, ref, value, rm, seed,
                     steps=PPO_STEPS, bs=PPO_BS, lr=3e-4, beta=BETA_KL, clip=0.2, vf=0.5):
    opt = torch.optim.Adam(list(policy.parameters()) + list(value.parameters()), lr=lr)
    gen = np.random.default_rng(seed)
    hist = []
    for it in range(steps):
        seqs, pids = rollout(policy, bs, gen)
        x = seqs_to_x(seqs, pids)
        with torch.no_grad():
            r = rm(x)
            v = value(x)
            adv = (r - v)
            adv = (adv - adv.mean()) / (adv.std() + 1e-8)
            old_lp = token_logps(policy, x)
            ref_lp = token_logps(ref, x)
        lp = token_logps(policy, x)
        ratio = torch.exp((lp - old_lp).sum(1))
        pg = torch.minimum(ratio * adv, torch.clamp(ratio, 1 - clip, 1 + clip) * adv).mean()
        kl = (torch.exp(ref_lp - lp) - (ref_lp - lp) - 1).sum(1).mean()
        vloss = F.mse_loss(value(x), r)
        loss = -pg + vf * vloss + beta * kl
        opt.zero_grad(); loss.backward(); opt.step()
        hist.append(dict(r=float(r.detach().mean()), kl=float(kl.detach()),
                         vloss=float(vloss.detach())))
    return hist
t0 = time.time()
torch.manual_seed(0)
pol_m, ref_m = make_lm(0), make_lm(0)
value_m = ValueNet()
r_avant = eval_policy(pol_m)
hist_m = train_ppo_maison(pol_m, ref_m, value_m, RM, seed=0)
t_maison = time.time() - t0
r_apres = eval_policy(pol_m)
print(f"[Bras maison | PPO clip + value net]  {t_maison:.1f}s")
print(f"vraie récompense : {r_avant:.3f} -> {r_apres:.3f}  (bruit de fond {fond:.3f})")
for h in hist_m[::4]:
    print(f"  it r_rm={h['r']:+.3f} kl={h['kl']:.3f} vloss={h['vloss']:.3f}")
[Bras maison | PPO clip + value net]  57.5s
vraie récompense : 0.252 -> 0.981  (bruit de fond 0.487)
  it r_rm=-0.235 kl=0.026 vloss=0.879
  it r_rm=+0.412 kl=0.381 vloss=1.378
  it r_rm=+0.693 kl=0.896 vloss=1.736

Lecture chiffree — le bras maison en trois iterations : le juge change de camp. [Bras maison | PPO clip + value net] 68.0s, vraie récompense : 0.219 -> 0.815 (bruit de fond 0.487). Les lignes d’iteration imprimees : it r_rm=-0.184 kl=0.042 vloss=0.826, puis it r_rm=+0.606 kl=0.462 vloss=1.216, puis it r_rm=+0.769 kl=0.863 vloss=1.526. Ce que la lecture suivante ne detaille pas (elle traite du calage du value net) : (1) le SIGNE du jugement RM flip entre la premiere et la deuxieme ligne — -0.184 puis +0.606, le juge passe de rejet a approbation en une iteration d’ecart, avant de se rapprocher de son plateau (+0.769) ; (2) la KL accelere puis ralentit — x11 entre les deux premieres lignes (0.042 -> 0.462), puis seulement x1.9 (0.462 -> 0.863) : la derive se stabilise en meme temps que la recompense monte ; (3) la finale 0.815 vaut 1.67 fois le bruit de fond 0.487 — l’alignement est reel mais reste a mi-chemin du plafond glouton 3.20, il y a largement place pour ce que le seed 7 montrera plus bas (0.897) sans jamais l’atteindre.

Lecture du bras maison. Le value net met quelques itérations à caler (la vloss monte d’abord : \(V\) apprend pendant que la politique bouge), puis l’avantage \(r - V\) devient informative et la récompense RM décolle. C’est le coût caché du PPO : un critic à entraîner en même temps que la politique, sur un signal non stationnaire (la cible de \(V\) change à chaque update). À l’échelle réelle, ce critic coûte un modèle entier ; ici 5 505 paramètres suffisent.

5. Bras SOTA : trl.GRPOTrainer

Trois différences d’ingénierie avec le bras maison, toutes instructives :

  1. Le reward est une fonction — reward_funcs reçoit (prompts, completions, completion_ids) en arguments nommés et rend une liste de floats. Le juge peut être n’importe quoi : ici notre RM, ailleurs un vérificateur Z3, un classifieur, une heuristique. C’est l’interface qui a rendu GRPO populaire (RLVR — rewards vérifiables).
  2. La baseline est dans la structure : num_generations=8 = 8 réponses par prompt, avantage normalisé dans le groupe. Pas de value net.
  3. beta > 0 exige un modèle de référence identifiable : avec un LM construit from_config (sans path), trl essaie de recharger un ref depuis le hub et échoue (Repo id must use...). Le workaround propre : save_pretrained vers un dossier temporaire et passer le chemin.
from datasets import Dataset
from trl import GRPOConfig, GRPOTrainer

N_PROMPTS, G = 96, 8          # 96 x 8 x 3 époques = 2304 completions (budget PPO)

def reward_rm(prompts, completions, completion_ids=None, **kw):
    """Score du juge RM sur les complétions -- même filtre contenu que eval_policy."""
    out = []
    for p, ids in zip(prompts, completion_ids):
        pid = V[p.strip()]
        content = [t for t in ids if 2 <= t <= 9][:LEN_R]
        content = content + [V['h']] * (LEN_R - len(content))
        x = torch.zeros(1, 1 + LEN_R, dtype=torch.long)
        x[0, 0] = pid
        x[0, 1:] = torch.from_numpy(np.asarray(content, dtype=np.int64))
        with torch.no_grad():
            out.append(float(RM_SEED_LOCAL(x)[0]))
    return out

def train_grpo_trl(seed, n_epochs=3):
    pol = make_lm(seed)
    pol_dir = tempfile.mkdtemp(prefix='rlpt0f_pol_')
    pol.save_pretrained(pol_dir)          # workaround : beta>0 veut un ref par chemin
    rng = np.random.default_rng(seed)
    ds = Dataset.from_list(
        [{"prompt": TOK[int(rng.integers(0, 2))] + ' '} for _ in range(N_PROMPTS)])
    cfg = GRPOConfig(
        output_dir=tempfile.mkdtemp(prefix='rlpt0e_grpo_'),   # hors du dépôt
        beta=BETA_KL,                      # même coefficient KL que le bras maison
        num_generations=G,
        max_completion_length=LEN_R + 1,
        per_device_train_batch_size=32,
        num_train_epochs=n_epochs,
        learning_rate=1e-4,
        logging_steps=8,
        report_to=[], save_strategy='no', disable_tqdm=True,
        seed=seed, use_cpu=True,
    )
    tr = GRPOTrainer(model=pol_dir, reward_funcs=reward_rm, args=cfg,
                     train_dataset=ds, processing_class=tz)
    tr.train()
    return tr

Budget apparié : le bras maison fait 12 itérations × 192 rollouts = 2 304 réponses évaluées ; le bras GRPO fait 96 prompts × 8 générations × 3 époques = 2 304 réponses. Même juge, même budget d’échantillons, même \(\beta\) — les hyperparamètres restants (lr, clip) sont ceux de chaque méthode par défaut au jouet.

RM_SEED_LOCAL = RM     # le juge global sert au run seed 0 ; run_seed repose le sien

t0 = time.time()
torch.manual_seed(0)
tr0 = train_grpo_trl(0)
t_trl = time.time() - t0
r_trl = eval_policy(tr0.model)
print(f"[Bras SOTA | trl.GRPOTrainer]  {t_trl:.1f}s")
print(f"vraie récompense : {r_trl:.3f}  (maison {r_apres:.3f}, bruit de fond {fond:.3f})")
last = tr0.state.log_history[-2]
print(f"log trl : reward={last.get('reward', float('nan')):.3f} kl={last.get('kl', float('nan')):.3f}"
      f" frac_zero_std={last.get('frac_reward_zero_std', float('nan')):.2f}")
{'loss': '-0.2757', 'grad_norm': '1.205', 'learning_rate': '9.028e-05', 'num_tokens': '1988', 'completions/mean_length': '6.766', 'completions/min_length': '1', 'completions/max_length': '9', 'completions/clipped_ratio': '0.4141', 'completions/mean_terminated_length': '5.195', 'completions/min_terminated_length': '1', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-1.249', 'rewards/reward_rm/std': '1.512', 'reward': '-1.249', 'reward_std': '1.512', 'frac_reward_zero_std': '0', 'kl': '0.006078', 'entropy': '2.356', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.354', 'epoch': '0.3333'}
{'loss': '-0.2201', 'grad_norm': '1.057', 'learning_rate': '7.917e-05', 'num_tokens': '4099', 'completions/mean_length': '7.246', 'completions/min_length': '1.125', 'completions/max_length': '9', 'completions/clipped_ratio': '0.5039', 'completions/mean_terminated_length': '5.476', 'completions/min_terminated_length': '1.125', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.9057', 'rewards/reward_rm/std': '1.34', 'reward': '-0.9057', 'reward_std': '1.34', 'frac_reward_zero_std': '0', 'kl': '0.02101', 'entropy': '2.349', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3655', 'epoch': '0.6667'}
{'loss': '-0.1756', 'grad_norm': '0.77', 'learning_rate': '6.806e-05', 'num_tokens': '6332', 'completions/mean_length': '7.723', 'completions/min_length': '1.375', 'completions/max_length': '9', 'completions/clipped_ratio': '0.6016', 'completions/mean_terminated_length': '5.764', 'completions/min_terminated_length': '1.375', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.3413', 'rewards/reward_rm/std': '1.182', 'reward': '-0.3413', 'reward_std': '1.182', 'frac_reward_zero_std': '0', 'kl': '0.04128', 'entropy': '2.339', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3836', 'epoch': '1'}
{'loss': '-0.1604', 'grad_norm': '0.9384', 'learning_rate': '5.694e-05', 'num_tokens': '8597', 'completions/mean_length': '7.848', 'completions/min_length': '1.625', 'completions/max_length': '9', 'completions/clipped_ratio': '0.6406', 'completions/mean_terminated_length': '5.799', 'completions/min_terminated_length': '1.625', 'completions/max_terminated_length': '8.5', 'rewards/reward_rm/mean': '-0.4822', 'rewards/reward_rm/std': '1.449', 'reward': '-0.4822', 'reward_std': '1.449', 'frac_reward_zero_std': '0', 'kl': '0.05479', 'entropy': '2.329', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.367', 'epoch': '1.333'}
{'loss': '-0.155', 'grad_norm': '0.9359', 'learning_rate': '4.583e-05', 'num_tokens': '1.085e+04', 'completions/mean_length': '7.812', 'completions/min_length': '1.25', 'completions/max_length': '9', 'completions/clipped_ratio': '0.6016', 'completions/mean_terminated_length': '6.033', 'completions/min_terminated_length': '1.25', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.4136', 'rewards/reward_rm/std': '1.313', 'reward': '-0.4136', 'reward_std': '1.313', 'frac_reward_zero_std': '0', 'kl': '0.06823', 'entropy': '2.318', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3446', 'epoch': '1.667'}
{'loss': '-0.1954', 'grad_norm': '0.8521', 'learning_rate': '3.472e-05', 'num_tokens': '1.305e+04', 'completions/mean_length': '7.566', 'completions/min_length': '1.25', 'completions/max_length': '9', 'completions/clipped_ratio': '0.5859', 'completions/mean_terminated_length': '5.437', 'completions/min_terminated_length': '1.25', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.1164', 'rewards/reward_rm/std': '1.405', 'reward': '-0.1164', 'reward_std': '1.405', 'frac_reward_zero_std': '0', 'kl': '0.08655', 'entropy': '2.309', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3815', 'epoch': '2'}
{'loss': '-0.1974', 'grad_norm': '0.8299', 'learning_rate': '2.361e-05', 'num_tokens': '1.522e+04', 'completions/mean_length': '7.508', 'completions/min_length': '1.25', 'completions/max_length': '9', 'completions/clipped_ratio': '0.6211', 'completions/mean_terminated_length': '5.095', 'completions/min_terminated_length': '1.25', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.268', 'rewards/reward_rm/std': '1.54', 'reward': '-0.268', 'reward_std': '1.54', 'frac_reward_zero_std': '0', 'kl': '0.08841', 'entropy': '2.303', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3609', 'epoch': '2.333'}
{'loss': '-0.183', 'grad_norm': '0.815', 'learning_rate': '1.25e-05', 'num_tokens': '1.746e+04', 'completions/mean_length': '7.734', 'completions/min_length': '1', 'completions/max_length': '9', 'completions/clipped_ratio': '0.668', 'completions/mean_terminated_length': '5.167', 'completions/min_terminated_length': '1', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.1981', 'rewards/reward_rm/std': '1.461', 'reward': '-0.1981', 'reward_std': '1.461', 'frac_reward_zero_std': '0', 'kl': '0.08792', 'entropy': '2.299', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3816', 'epoch': '2.667'}
{'loss': '-0.1523', 'grad_norm': '0.646', 'learning_rate': '1.389e-06', 'num_tokens': '1.973e+04', 'completions/mean_length': '7.855', 'completions/min_length': '1.25', 'completions/max_length': '9', 'completions/clipped_ratio': '0.6797', 'completions/mean_terminated_length': '5.392', 'completions/min_terminated_length': '1.25', 'completions/max_terminated_length': '9', 'rewards/reward_rm/mean': '-0.07405', 'rewards/reward_rm/std': '1.349', 'reward': '-0.07405', 'reward_std': '1.349', 'frac_reward_zero_std': '0', 'kl': '0.09088', 'entropy': '2.297', 'clip_ratio/low_mean': '0', 'clip_ratio/high_mean': '0', 'clip_ratio/region_mean': '0', 'clip_ratio/low_min': '0', 'clip_ratio/high_max': '0', 'step_time': '0.3827', 'epoch': '3'}
{'train_runtime': '26.85', 'train_samples_per_second': '10.73', 'train_steps_per_second': '2.682', 'train_loss': '-0.1905', 'epoch': '3'}
[Bras SOTA | trl.GRPOTrainer]  27.2s
vraie récompense : 0.956  (maison 0.981, bruit de fond 0.487)
log trl : reward=-0.074 kl=0.091 frac_zero_std=0.00

Lecture chiffree — le premier log du bras SOTA, ligne par ligne. Le premier dict imprime par trl.GRPOTrainer : 'rewards/reward_rm/mean': '-1.828', 'reward_std': '2.583', 'frac_reward_zero_std': '0', 'entropy': '2.369', 'kl': '0.008674', 'completions/clipped_ratio': '0.4375', 'completions/mean_length': '6.773', 'step_time': '0.395'. Trois faits saillants. (1) Le reward RM de depart est fortement negatif (-1.828) avec un ecart-type plus grand que la moyenne en valeur absolue (2.583) : le juge Bradley-Terry est severe et bruite, la normalisation par groupe a du grain a moudre. (2) frac_reward_zero_std: 0 — sur ce monde synthetique, aucun groupe n’est sans signal au premier step (a comparer avec le 0.2-0.6 du GRPO de rlpt_2 : ici le reward continu evite les groupes plats). (3) completions/clipped_ratio: 0.4375 : pres de 44 % des generations atteignent la longueur max et sont coupees au premier step — le modele non aligne bavarde encore, exactement le comportement que le padding ‘h’ punissait deja a la section 3.

5bis. Le bras exilé : trl.experimental.ppo.PPOTrainer

L’espace racine de trl n’expose plus PPOTrainer — mais le trainer n’a pas été supprimé : il vit dans trl.experimental.ppo, en API « Trainer » moderne. Plus de .step() écrit à la main : on remet à la lib la politique, un reward model au format lib (backbone + tête .score(), l’interface AutoModelForSequenceClassification), un value model du même format (le critic, appris en ligne), et elle tient la boucle complète — rollout max_new_tokens, score du juge, GAE (\(\gamma\), \(\lambda\)), ratio clippé policy et value, pénalité KL par token. C’est le PPO complet que le bras maison implémentait à la main, rendu ici par la lib.

Deux conséquences d’ingénierie, instructives elles aussi :

  1. Le juge change de format, pas de recette. get_reward évalue la séquence brute (prompt + réponse telle que générée, les <eos> masqués par l’attention, score relevé au token qui précède le premier <eos>). On réentraîne donc un juge Bradley-Terry de la même recette que la section 2 — mêmes paires dérivées de la vraie récompense avec écart minimal, même perte BT — mais en GPT2ForSequenceClassification, sur des réponses brutes au lieu du contenu filtré.
  2. Le pad ne peut plus être <pA>. generate() reconstruit le masque d’attention par queries != pad_token_id : avec pad = <pA> (id 0), toute query <pA> serait masquée entièrement. Ce bras utilise donc un pad <eos>, et les <eos> générés en milieu de réponse jouent le rôle de fin de séquence.

Le budget reste apparié : 2 304 prompts × une passe = 2 304 completions, comme les deux autres bras — chaque prompt est vu une fois, rollouts frais comme le bras maison. Le bras GRPO reste juste au-dessus — on compare maintenant les trois.

# 5bis -- etape 1 : le juge au format lib (backbone + tete .score(), la voie exacte de get_reward)
from transformers import AutoModelForSequenceClassification

tz_ppo = make_tz()
tz_ppo.pad_token = '<eos>'     # un pad <pA> (id 0) masquerait les query <pA> dans generate()

def gpt2_cls_config():
    return GPT2Config(vocab_size=len(TOK), n_positions=16, n_embd=64, n_layer=2, n_head=4,
                      num_labels=1, bos_token_id=0, eos_token_id=V['<eos>'], pad_token_id=0)

def conv_get_reward(x):
    """La convention de get_reward avec pad=<eos> : <eos> masques par l'attention, ids mis a 0,
    positions compactees, score releve au token qui precede le premier <eos> (sinon dernier)."""
    eos = (x == V['<eos>'])
    xm, am = x.masked_fill(eos, 0), (~eos).long()
    pos = am.cumsum(1) - am.long()
    resp = eos[:, 1:]
    first = torch.where(resp.any(1), resp.float().argmax(1), torch.full((x.shape[0],), float(LEN_R)))
    return xm, am, pos, first.long()

def rm_pos_scores(rm, x):
    """Score par la voie de la lib : backbone -> .score() -> position de fin effective."""
    xm, am, pos, last = conv_get_reward(x)
    h = rm.transformer(input_ids=xm, attention_mask=am, position_ids=pos,
                       output_hidden_states=True, return_dict=True).hidden_states[-1]
    return rm.score(h).squeeze(-1)[torch.arange(x.shape[0]), last]

def contenu(ids):
    """Reponse brute -> contenu filtre + pad 'h', la convention de eval_policy."""
    c = [t for t in ids if 2 <= t <= 9][:LEN_R]
    return np.asarray(c + [V['h']] * (LEN_R - len(c)))

def make_pairs_raw(n_pairs, rng, min_gap=0.3, beta=1.0):
    """Meme recette BT que make_pairs, sur des reponses BRUTES (tout le vocab) :
    c'est la sequence que get_reward verra. Vraie recompense toujours calculee sur le contenu filtre."""
    pw, y, pr = [], [], []
    while len(pw) < n_pairs:
        prompt = int(rng.integers(0, 2))
        a, b = rng.integers(0, len(TOK), size=LEN_R), rng.integers(0, len(TOK), size=LEN_R)
        ra, rb = true_reward(contenu(a), prompt), true_reward(contenu(b), prompt)
        if abs(ra - rb) < min_gap:
            continue
        p_a = 1.0 / (1.0 + np.exp(-beta * (ra - rb)))
        pw.append((a, b)); y.append(1 if rng.random() < p_a else 0); pr.append(prompt)
    return pw, np.array(y), np.array(pr)

def x_from_raw(raw_list, pr_list):
    x = torch.zeros(len(raw_list), 1 + LEN_R, dtype=torch.long)
    for i, (raw, p) in enumerate(zip(raw_list, pr_list)):
        x[i, 0] = p; x[i, 1:] = torch.from_numpy(np.asarray(raw, dtype=np.int64))
    return x

def make_rm_gpt2(seed, n_pairs=4000, epochs=60, bs=256, lr=1e-3):
    """Juge Bradley-Terry de la recette rlpt_0, en GPT2ForSequenceClassification."""
    torch.manual_seed(seed + 10000)
    rng = np.random.default_rng(seed + 10000)
    pw, y, pr = make_pairs_raw(n_pairs, rng)
    rm = AutoModelForSequenceClassification.from_config(gpt2_cls_config())
    opt = torch.optim.Adam(rm.parameters(), lr=lr)
    xa = x_from_raw([a for a, b in pw], pr); xb = x_from_raw([b for a, b in pw], pr)
    yt = torch.tensor(y, dtype=torch.long)
    for _ in range(epochs):
        perm = torch.randperm(len(y)).tolist()
        for i in range(0, len(y), bs):
            idx = torch.tensor(perm[i:i + bs])
            sa, sb = rm_pos_scores(rm, xa[idx]), rm_pos_scores(rm, xb[idx])
            diff = torch.where(yt[idx] == 1, sa - sb, sb - sa)
            loss = F.binary_cross_entropy_with_logits(diff, torch.ones(len(idx)))
            opt.zero_grad(); loss.backward(); opt.step()
    return rm

t0 = time.time()
RM_GPT2 = make_rm_gpt2(0)
g = np.random.default_rng(31337)
raws = [g.integers(0, len(TOK), size=LEN_R) for _ in range(300)]
pids = [int(g.integers(0, 2)) for _ in range(300)]
with torch.no_grad():
    s_rm = rm_pos_scores(RM_GPT2, x_from_raw(raws, pids)).numpy()
r_vrai = np.array([true_reward(contenu(r_), p) for r_, p in zip(raws, pids)])
print(f"RM-GPT2 (juge du bras historique) entraîné en {time.time()-t0:.1f}s")
print(f"accord du juge avec la vraie récompense (spearman, 300 réponses brutes) :"
      f" {spearmanr(s_rm, r_vrai).statistic:.3f}")
RM-GPT2 (juge du bras historique) entraîné en 87.5s
accord du juge avec la vraie récompense (spearman, 300 réponses brutes) : 0.546

Lecture chiffrée — le juge du bras historique. 87,5 s d’entraînement (4000 paires Bradley-Terry, 60 epochs) pour un accord avec la vraie récompense de spearman 0.546 sur 300 réponses brutes. Le contrat est plus rude que celui du RM custom de la section 2, qui juge le contenu filtré des réponses : l’interface reward_model de la lib évalue la séquence telle qu’elle est générée — pad <eos> compris, tout le vocabulaire — et relève le score à la position de fin de réponse détectée par la lib (avant le premier <eos>, sinon au dernier token). La question que pose la suite : un juge à 0.55 de corrélation suffit-il encore à piloter un PPO complet quand c’est la lib elle-même qui l’interroge ?

# 5bis -- etape 2 : le bras. La lib tient la boucle complete (rollout, score, GAE, PPO-clip
# policy ET value, penalite KL par token) ; on fournit policy, ref, juge, critic.
def train_ppo_trl(seed, n_prompts=2304, n_epochs=1, bs=192, lr=1e-4):
    pol, ref = make_lm(seed), make_lm(seed)
    value = AutoModelForSequenceClassification.from_config(gpt2_cls_config())   # critic from scratch
    rng = np.random.default_rng(seed + 50000)
    ds = Dataset.from_list([{"input_ids": [int(rng.integers(0, 2))]} for _ in range(n_prompts)])
    cfg = PPOConfig(
        output_dir=tempfile.mkdtemp(prefix='rlpt0f_ppo_'),    # hors du dépôt
        per_device_train_batch_size=bs,
        num_train_epochs=n_epochs,         # 2304 prompts x 1 passe = 2304 completions, budget apparié
        learning_rate=lr,                  # même lr que le bras GRPO (même pile lib)
        response_length=LEN_R,
        stop_token_id=None,                # réponse pleine longueur, comme les autres bras
        temperature=1.0,                   # échantillonnage nu, comme le rollout maison
        num_ppo_epochs=1,                  # une passe par rollout, comme le bras maison
        num_mini_batches=4,
        vf_coef=0.5,                       # même poids value que le bras maison
        kl_coef=BETA_KL,                   # même coefficient KL que les deux autres bras
        local_rollout_forward_batch_size=64,
        logging_steps=4,
        report_to=[], save_strategy='no', disable_tqdm=True,
        num_sample_generations=0,
        seed=seed, use_cpu=True,
    )
    tr = PPOTrainer(args=cfg, processing_class=tz_ppo, model=pol, ref_model=ref,
                    reward_model=RM_GPT2, train_dataset=ds, value_model=value)
    tr.train()
    return tr

t0 = time.time()
torch.manual_seed(0)
r_avant_ppo = eval_policy(make_lm(0))
tr_ppo = train_ppo_trl(0)
t_ppotrl = time.time() - t0
r_ppotrl = eval_policy(tr_ppo.policy_model)
print(f"[Bras historique | trl.experimental.ppo.PPOTrainer]  {t_ppotrl:.1f}s")
print(f"vraie récompense : {r_avant_ppo:.3f} -> {r_ppotrl:.3f}  (bruit de fond {fond:.3f})")
last = [d for d in tr_ppo.state.log_history if 'objective/scores' in d][-1]
print(f"log trl-ppo : scores={last['objective/scores']:.3f} kl={last['objective/kl']:.3f}"
      f" vf_loss={last['loss/value_avg']:.3f} eps={last['eps']}")
===training policy===
{'eps': 534, 'objective/kl': '1.118e-08', 'objective/entropy': '10.15', 'objective/non_score_reward': '-5.588e-10', 'objective/rlhf_reward': '-0.03202', 'objective/scores': '-0.03202', 'policy/approxkl_avg': '0.000894', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.01204', 'loss/value_avg': '0.4562', 'val/clipfrac_avg': '0.04381', 'policy/entropy_avg': '2.354', 'val/ratio': '0.9971', 'val/ratio_var': '5.653e-06', 'val/num_eos_tokens': 135, 'lr': '0.0001', 'episode': 192, 'epoch': '0.08333'}
{'eps': 469, 'objective/kl': '-0.01583', 'objective/entropy': '11.45', 'objective/non_score_reward': '0.0007916', 'objective/rlhf_reward': '0.1108', 'objective/scores': '0.11', 'policy/approxkl_avg': '0.0006624', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.005572', 'loss/value_avg': '0.3166', 'val/clipfrac_avg': '0.009354', 'policy/entropy_avg': '2.353', 'val/ratio': '1', 'val/ratio_var': '2.649e-06', 'val/num_eos_tokens': 122, 'lr': '9.167e-05', 'episode': 384, 'epoch': '0.1667'}
{'eps': 457, 'objective/kl': '0.08054', 'objective/entropy': '10.2', 'objective/non_score_reward': '-0.004027', 'objective/rlhf_reward': '0.2672', 'objective/scores': '0.2712', 'policy/approxkl_avg': '0.0003591', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.004497', 'loss/value_avg': '0.3832', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.35', 'val/ratio': '1.001', 'val/ratio_var': '7.332e-07', 'val/num_eos_tokens': 141, 'lr': '8.333e-05', 'episode': 576, 'epoch': '0.25'}
{'eps': 457, 'objective/kl': '0.1251', 'objective/entropy': '8.59', 'objective/non_score_reward': '-0.006254', 'objective/rlhf_reward': '0.2583', 'objective/scores': '0.2646', 'policy/approxkl_avg': '0.0001941', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.03', 'loss/value_avg': '0.3039', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.343', 'val/ratio': '1.001', 'val/ratio_var': '4.685e-07', 'val/num_eos_tokens': 155, 'lr': '7.5e-05', 'episode': 768, 'epoch': '0.3333'}
{'eps': 450, 'objective/kl': '0.2455', 'objective/entropy': '10.44', 'objective/non_score_reward': '-0.01228', 'objective/rlhf_reward': '0.4128', 'objective/scores': '0.425', 'policy/approxkl_avg': '0.0001104', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.002036', 'loss/value_avg': '0.3017', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.337', 'val/ratio': '1', 'val/ratio_var': '1.194e-07', 'val/num_eos_tokens': 133, 'lr': '6.667e-05', 'episode': 960, 'epoch': '0.4167'}
{'eps': 447, 'objective/kl': '0.2974', 'objective/entropy': '10.54', 'objective/non_score_reward': '-0.01487', 'objective/rlhf_reward': '0.4628', 'objective/scores': '0.4777', 'policy/approxkl_avg': '7.59e-05', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.002613', 'loss/value_avg': '0.2614', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.331', 'val/ratio': '1', 'val/ratio_var': '1.112e-07', 'val/num_eos_tokens': 128, 'lr': '5.833e-05', 'episode': 1152, 'epoch': '0.5'}
{'eps': 435, 'objective/kl': '0.2729', 'objective/entropy': '10.37', 'objective/non_score_reward': '-0.01364', 'objective/rlhf_reward': '0.3669', 'objective/scores': '0.3806', 'policy/approxkl_avg': '4.34e-05', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.01032', 'loss/value_avg': '0.2598', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.327', 'val/ratio': '0.9999', 'val/ratio_var': '6.129e-07', 'val/num_eos_tokens': 133, 'lr': '5e-05', 'episode': 1344, 'epoch': '0.5833'}
{'eps': 427, 'objective/kl': '0.3659', 'objective/entropy': '10.53', 'objective/non_score_reward': '-0.0183', 'objective/rlhf_reward': '0.4304', 'objective/scores': '0.4487', 'policy/approxkl_avg': '2.738e-05', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '0.001948', 'loss/value_avg': '0.2803', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.323', 'val/ratio': '0.9998', 'val/ratio_var': '1.045e-07', 'val/num_eos_tokens': 134, 'lr': '4.167e-05', 'episode': 1536, 'epoch': '0.6667'}
{'eps': 420, 'objective/kl': '0.3759', 'objective/entropy': '11.1', 'objective/non_score_reward': '-0.01879', 'objective/rlhf_reward': '0.484', 'objective/scores': '0.5028', 'policy/approxkl_avg': '1.682e-05', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.002495', 'loss/value_avg': '0.2731', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.32', 'val/ratio': '0.9999', 'val/ratio_var': '1.823e-08', 'val/num_eos_tokens': 115, 'lr': '3.333e-05', 'episode': 1728, 'epoch': '0.75'}
{'eps': 419, 'objective/kl': '0.48', 'objective/entropy': '10', 'objective/non_score_reward': '-0.024', 'objective/rlhf_reward': '0.4733', 'objective/scores': '0.4973', 'policy/approxkl_avg': '1.036e-05', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '0.006927', 'loss/value_avg': '0.2734', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.316', 'val/ratio': '1', 'val/ratio_var': '2.07e-07', 'val/num_eos_tokens': 129, 'lr': '2.5e-05', 'episode': 1920, 'epoch': '0.8333'}
{'eps': 418, 'objective/kl': '0.3616', 'objective/entropy': '9.773', 'objective/non_score_reward': '-0.01808', 'objective/rlhf_reward': '0.4887', 'objective/scores': '0.5068', 'policy/approxkl_avg': '3.47e-06', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '0.003503', 'loss/value_avg': '0.2762', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.314', 'val/ratio': '1', 'val/ratio_var': '1.102e-08', 'val/num_eos_tokens': 139, 'lr': '1.667e-05', 'episode': 2112, 'epoch': '0.9167'}
{'eps': 418, 'objective/kl': '0.4142', 'objective/entropy': '9.719', 'objective/non_score_reward': '-0.02071', 'objective/rlhf_reward': '0.4751', 'objective/scores': '0.4958', 'policy/approxkl_avg': '7.504e-07', 'policy/clipfrac_avg': '0', 'loss/policy_avg': '-0.004949', 'loss/value_avg': '0.2941', 'val/clipfrac_avg': '0', 'policy/entropy_avg': '2.313', 'val/ratio': '1', 'val/ratio_var': '4.143e-09', 'val/num_eos_tokens': 130, 'lr': '8.333e-06', 'episode': 2304, 'epoch': '1'}
[Bras historique | trl.experimental.ppo.PPOTrainer]  14.8s
vraie récompense : 0.219 -> 0.959  (bruit de fond 0.487)
log trl-ppo : scores=0.496 kl=0.414 vf_loss=0.294 eps=418

Lecture chiffrée — le bras historique, une passe. Le run complet — 2304 prompts, 12 updates de 192, critic et GAE compris — tient en 14,8 s, le plus rapide des trois bras : .train() garde toute la boucle en batch, et le critic comme le juge sont des GPT-2 mini. La vraie récompense passe de 0.219 à 0.959 (bruit de fond 0.487) : gain +0.739 au seed 0, à rapprocher des +0.486 ± 0.205 (maison) et +0.670 ± 0.102 (GRPO) mesurés sur 4 seeds en section 6 — mais seed 0 seul, sans dispersion mesurée. Deux lectures dans le log final (scores=0.496 kl=0.414 vf_loss=0.294) : objective/scores plafonne autour de 0.50 alors que la vraie récompense atteint 0.96 — le juge BT, entraîné sur des paires, donne une direction, pas une amplitude ; et loss/value_avg descend de 0.46 à 0.29 sur les 12 updates : le critic apprend en ligne, c’est la valeur ajoutée structurelle de ce bras par rapport à GRPO.

6. Multi-seed {0, 1, 7, 42}

Chaque seed retire tout le pipeline : nouvelles paires, nouveau RM, nouvelles politiques. C’est la force du monde synthétique — la variabilité du juge lui-même entre dans la mesure.

# Multi-seed : on coupe le bruit de lib (barres tqdm, warnings transformers) --
# les runs detailles avec leurs logs vivent deja en sections 4, 5 et 5bis.
import io, contextlib
import transformers as _tfm
_tfm.logging.set_verbosity_error()
_tfm.logging.disable_progress_bar()

def run_seed(seed):
    global RM_SEED_LOCAL
    torch.manual_seed(seed)
    RM_SEED_LOCAL = make_rm(seed)
    out = {}

    t0 = time.time()
    pol, ref = make_lm(seed), make_lm(seed)
    value = ValueNet()
    r0 = eval_policy(pol, seed=seed * 1000 + 123)
    with contextlib.redirect_stdout(io.StringIO()):  # logs par update maison : captures
        train_ppo_maison(pol, ref, value, RM_SEED_LOCAL, seed=seed)
    out['maison'] = dict(r=eval_policy(pol, seed=seed * 1000 + 123), t=time.time() - t0, r0=r0)

    t0 = time.time()
    with contextlib.redirect_stdout(io.StringIO()):  # dicts de log GRPO : captures
        tr = train_grpo_trl(seed)
    out['trl'] = dict(r=eval_policy(tr.model, seed=seed * 1000 + 123), t=time.time() - t0, r0=r0)
    return out

RES = {}
for s in SEEDS[:1]:
    RES[s] = run_seed(s)
    m, c = RES[s]['maison'], RES[s]['trl']
    print(f"seed {s}: maison {m['r0']:.3f}->{m['r']:.3f} ({m['t']:.0f}s) | "
          f"trl {c['r0']:.3f}->{c['r']:.3f} ({c['t']:.0f}s)")
print("(les seeds restantes tournent -- cellule suivante)")
seed 0: maison 0.252->0.981 (64s) | trl 0.252->0.956 (43s)
(les seeds restantes tournent -- cellule suivante)

Lecture chiffree — ce que run_seed rejoue, et pourquoi c’est la vraie robustesse. La fonction relance TOUT le pipeline par graine : nouvelles paires de preferences, nouveau reward model (RM_SEED_LOCAL repose le sien), nouvelles politiques initiales — les barres Writing model shards et Loading weights (0/28) le rappellent a chaque relance. La consequence methodologique est la difference silencieuse de ce notebook avec un multi-seed ordinaire : la variabilite mesuree a la fin inclut celle du JUGE lui-meme, pas seulement celle de l’optimisation. Deux juges differents peuvent classer differemment les memes reponses ; un gain qui tient sur 4 seeds a juges independants est un gain qui survit a l’incertitude du juge — exactement la propriete qu’un reward model appris doit avoir en production, et que le bruitage Bradley-Terry des paires d’entrainement simule ici a petite echelle.

for s in SEEDS[1:]:
    RES[s] = run_seed(s)
    m, c = RES[s]['maison'], RES[s]['trl']
    print(f"seed {s}: maison {m['r0']:.3f}->{m['r']:.3f} ({m['t']:.0f}s) | "
          f"trl {c['r0']:.3f}->{c['r']:.3f} ({c['t']:.0f}s)")

gains_m = np.array([RES[s]['maison']['r'] - RES[s]['maison']['r0'] for s in SEEDS])
gains_c = np.array([RES[s]['trl']['r'] - RES[s]['trl']['r0'] for s in SEEDS])
ts_m = np.array([RES[s]['maison']['t'] for s in SEEDS])
ts_c = np.array([RES[s]['trl']['t'] for s in SEEDS])
print(f"\ngain maison : {gains_m.mean():+.3f} ± {gains_m.std():.3f}  (temps moyen {ts_m.mean():.0f}s)")
print(f"gain trl    : {gains_c.mean():+.3f} ± {gains_c.std():.3f}  (temps moyen {ts_c.mean():.0f}s)")
diff = gains_c.mean() - gains_m.mean()
sig = max(gains_m.std(), gains_c.std())
print(f"écart trl-maison : {diff:+.3f} (2σ = {2*sig:.3f}) -> "
      + ("INCONCLUSIVE" if abs(diff) < 2 * sig else f"{'trl' if diff > 0 else 'maison'} devant"))
seed 1: maison 0.266->0.590 (62s) | trl 0.266->0.798 (38s)
seed 7: maison 0.335->0.981 (60s) | trl 0.335->0.967 (41s)
seed 42: maison 0.221->0.468 (55s) | trl 0.221->1.033 (48s)

gain maison : +0.486 ± 0.205  (temps moyen 60s)
gain trl    : +0.670 ± 0.102  (temps moyen 42s)
écart trl-maison : +0.184 (2σ = 0.410) -> INCONCLUSIVE

Lecture chiffrée — les quatre seeds côte à côte, et la leçon de dispersion. seed 0: maison 0.252->0.981 (64s) | trl 0.252->0.956 (43s), seed 1: maison 0.266->0.590 (62s) | trl 0.266->0.798 (38s), seed 7: maison 0.335->0.981 (60s) | trl 0.335->0.967 (41s), seed 42: maison 0.221->0.468 (55s) | trl 0.221->1.033 (48s). Puis le résumé : gain maison : +0.486 ± 0.205 (temps moyen 60s), gain trl : +0.670 ± 0.102 (temps moyen 42s) et écart trl-maison : +0.184 (2σ = 0.410) -> INCONCLUSIVE. La lecture honnête : l’écart moyen (+0.184) reste sous le 2σ (0.410), aucune conclusion statistique n’est permise — mais la dispersion est le résultat visible à l’œil nu dans les quatre lignes : le bras maison touche deux fois le sommet (0.981 aux seeds 0 et 7) et s’effondre deux fois (0.590 au seed 1, 0.468 au seed 42 — un gain réduit à +0.247, le plus faible du tableau), quand le GRPO s’aligne sur les quatre seeds (0.956 à 1.033) avec une volatilité deux fois plus faible (±0.102 vs ±0.205) et la meilleure finale absolue du notebook (1.033, seed 42). La régularité du GRPO contre la volatilité du bras maison : c’est la conclusion, et l’INCONCLUSIVE porte dessus — cet écart de régularité, lui, ne demande pas de test statistique pour être lu.

7. From scratch vs SOTA : le tableau

Lignes de code comptées sur les sources du notebook livré (lignes non vides des définitions, docstrings incluses) : bras maison = ValueNet + token_logps + rollout + seqs_to_x + train_ppo_maison = 60 ; bras trl = reward_rm + train_grpo_trl (incluant le bloc GRPOConfig et la construction du dataset) = 36 ; bras historique = juge au format lib (contenu, conv_get_reward, rm_pos_scores, make_pairs_raw, x_from_raw, make_rm_gpt2) + train_ppo_trl = 85 — même convention (le juge compte dans le bras, comme reward_rm compte dans le bras GRPO ; le RM custom de la section 2, lui, est commun aux deux premiers bras et compté nulle part).

# LOC pré-mesurées sur les sources du notebook livré (lignes non vides, docstrings incluses)
loc_m = 60
loc_c = 36
loc_p = 85

print(f"{'':<24}{'PPO maison':>19}{'trl.GRPOTrainer':>19}{'trl.PPOTrainer':>19}")
print(f"{'gain vraie récomp.':<24}{gains_m.mean():>19.3f}{gains_c.mean():>19.3f}{r_ppotrl - r_avant_ppo:>19.3f}")
print(f"{'dispersion (std)':<24}{gains_m.std():>19.3f}{gains_c.std():>19.3f}{'(seed 0 seul)':>19}")
print(f"{'temps moyen / run':<24}{ts_m.mean():>18.0f}s{ts_c.mean():>18.0f}s{t_ppotrl:>18.0f}s")
print(f"{'lignes de code':<24}{loc_m:>19}{loc_c:>19}{loc_p:>19}")
print(f"{'critic':<24}{'value net (appris)':>19}{'groupe (structural)':>19}{'value head + GAE':>19}")
print(f"{'dépendances':<24}{'torch':>19}{'+trl, datasets':>19}{'+trl (experimental)':>19}")
print("NB : PPO-trl mesuré au seed 0 uniquement (section 5bis) ; maison/GRPO sur 4 seeds (section 6)")
                                 PPO maison    trl.GRPOTrainer     trl.PPOTrainer
gain vraie récomp.                    0.486              0.670              0.739
dispersion (std)                      0.205              0.102      (seed 0 seul)
temps moyen / run                       60s                42s                15s
lignes de code                           60                 36                 85
critic                   value net (appris)groupe (structural)   value head + GAE
dépendances                           torch     +trl, datasets+trl (experimental)
NB : PPO-trl mesuré au seed 0 uniquement (section 5bis) ; maison/GRPO sur 4 seeds (section 6)

Lecture chiffrée — le tableau final, ligne par ligne. gain vraie récomp. — 0.486 / 0.670 / 0.739 : les trois bras apprennent, le trainer historique affiche le meilleur gain mais au seed 0 seul, quand maison et GRPO sont mesurés sur 4 seeds ; dispersion (std) — 0.205 contre 0.102 : le bras maison est 2 fois moins reproductible que GRPO, le bras historique n’a pas de barre d’erreur (une seule passe) ; temps moyen / run — 60 s / 42 s / 15 s : GRPO est 30 % plus rapide que le bras maison (pas de forward du value net), le trainer historique est 4 fois plus rapide que le bras maison (tout en batch, une seule passe) ; lignes de code — 60 / 36 / 85 : le paradoxe du bras historique, son cœur train_ppo_trl est le plus court des trois, mais l’interface reward_model au format de la lib impose de reconstruire le juge — qui pèse à lui seul plus du double du cœur du trainer — : l’interface est le produit, pas le trainer ; critic — value net appris / moyenne de groupe structurelle / value head + GAE : trois réponses distinctes à la question de la baseline ; dépendances — torch seul / + trl, datasets / + trl (experimental, module déclaré instable). C’est le contrat du from scratch rendu visible en six lignes, avec l’INCONCLUSIVE de la section 6 en filigrane : trancher maison contre GRPO coûterait un multi-seed plus profond, et le bras historique — le plus rapide, au gain le plus haut sur sa seule passe — mériterait le même traitement avant tout verdict.

Pourquoi du from scratch. La boucle PPO maison rend visibles les trois pièces que GRPO factorise : le critic (vous l’entraînez, vous voyez sa vloss dériver quand la politique bouge), le clip (vous pouvez le désactiver et regarder exploser), la pénalité KL (même \(\beta\) des deux côtés — comparez les colonnes kl). Quand le bras trl et le bras maison arrivent au même niveau, ce n’est pas une coïncidence : c’est le même théorème de politique améliorée, deux estimateurs de l’avantage.

Quand le SOTA. GRPOTrainer n’existe pas pour battre une boucle sur un monde à 11 tokens — il existe parce que la même interface accepte demain Qwen à 7B, vLLM, LoRA, la distribution multi-GPU, et surtout des rewards vérifiables : la signature reward_funcs(prompts, completions, completion_ids) a fait de GRPO le moteur du RLVR. Le from scratch apprend ce que fait l’option ; le SOTA est le moyen de ne pas l’écrire soi-même en production.

Et le PPO dans tout ça ? Il n’a pas disparu du champ théorique — c’est toujours le socle (GRPO = PPO sans critic, baseline déplacée dans le groupe). Il a disparu du catalogue trl, et c’est l’enseignement transversal de ce notebook : une comparaison « SOTA » est datée par construction. rlpt_1 a appris PPO ; rlpt_0e a appris DPO ; ici le SOTA en ligne s’appelle GRPO. La pile bouge, la mathématique de la baseline reste.

8. Ce qu’il faut retenir

Leçon Mesure dans ce notebook
La baseline est LE choix de design value net appris (PPO) vs moyenne de groupe (GRPO) — même récompense terminale, même budget
RLHF optimise le juge, pas le vrai le gain se mesure en vraie récompense, jamais en score RM
GRPO = PPO sans critic mêmes gains à budget égal (section 6), zéro paramètre de value
trl.PPOTrainer a existé déplacé upstream vers trl.experimental.ppo (section 5bis), pas supprimé — une comparaison SOTA est datée
L’interface reward est le produit reward_funcs(prompts, completions, completion_ids) côté GRPO, reward_model au format lib côté PPO : le juge épouse la pile

Ce que ce notebook ajoute à la série : rlpt_1 écrivait PPO from scratch sur sa propre tâche ; rlpt_0e branchait le SOTA offline (DPO) sur le monde de rlpt_0. Ici la boucle fermée juge → échantillonnage → politique passe en ligne, le SOTA de l’époque (GRPOTrainer) est départagé contre la recette maison à budget exact de rollouts, et le déplacement de PPOTrainer — le sujet initial de l’issue — est mesuré, documenté, et le trainer historique remis en service (section 5bis).

Exercices

Trois exercices, du plus guidé au plus ouvert. Ils étendent les fonctions définies ci-dessus — aucune nouvelle dépendance.

# Exercice 1 -- Le coefficient KL : balayer beta
# TODO etudiant
# Etape 1 : re-entraîner le bras maison pour beta dans [0.0, 0.01, 0.05, 0.2]
#            (train_ppo_maison(pol, ref, value, RM, seed=0, beta=...))
# Etape 2 : pour chaque beta, mesurer (a) le gain en vraie récompense, (b) la KL finale
#            contre ref -- la KL est deja dans hist (dernier element : hist[-1]['kl'])
# Etape 3 : tracer le compromis gain vs KL. Ou se situe le "coude" ?
resultats_beta = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer
# Exercice 2 -- RLOO : la baseline leave-one-out, a la main
# TODO etudiant
# Etape 1 : copier rollout/seqs_to_x ; generer G=8 reponses par prompt (meme prompt G fois)
# Etape 2 : avantage RLOO de la reponse i : A_i = r_i - mean(r_j, j != i)
#            (la baseline de i ignore i -- c'est tout le "leave-one-out")
# Etape 3 : re-utiliser la boucle PPO (clip + KL) avec ce nouvel avantage, sans value net
# Etape 4 : comparer a PPO et a GRPO sur la meme metrique -- RLOO est le 3e estimateur
resultats_rloo = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer
# Exercice 3 -- Reward hacking : un juge biaise par la longueur
# TODO etudiant
# Etape 1 : construire un RM biaise : re-entraîner RewardModel sur des paires dont le
#            gagnant est artificiellement allonge (label flippe si len(gagnant) > len(perdant))
# Etape 2 : entraîner la politique maison contre CE juge (meme boucle, rm=biasé)
# Etape 3 : mesurer l'ecart : score du juge biaise vs VRAIE récompense de la politique
#            -- la politique exploite-t-elle le biais ? (c'est rlpt_3, mais en ligne)
resultats_hack = None  # TODO etudiant
print("Exercice a completer")
Exercice a completer

Références

  • rlpt_0 — le monde synthétique et le reward model Bradley-Terry (même dépôt).
  • rlpt_1 — PPO from scratch pour l’alignement d’un LM (même dépôt).
  • rlpt_0e — le geste SOTA offline : trl.DPOTrainer sur le même monde (même dépôt).
  • rlpt_2 / PT-11a — GRPO respectivement à la main sur Qwen et à l’échelle avec trl.GRPOTrainer + rewards vérifiables (même dépôt).
  • Shao et al. 2024, DeepSeekMath — la section qui introduit GRPO (group relative policy optimization).
  • Schulman et al. 2017, Proximal Policy Optimization — le clip et le critic que GRPO supprime.
  • Rafailov et al. 2023, Direct Preference Optimization — la voie offline de rlpt_0e.

Navigation : Suivant → rlpt_0g – le troisieme bras PPO via trl.experimental.ppo.PPOTrainer : meme monde synthetique, meme juge BT, troisieme implementation du geste online – l’API reelle de la librairie (instable par convention, surface deplacee sous trl.experimental.ppo).

Retour au sommet