RL Post-Training — 2 : GRPO minimal — alignement d’un petit modèle par RL avec récompense vérifiable

Ce notebook déroule la mécanique GRPO (Group Relative Policy Optimization, Shao et al. 2024) sur un cas minimal et vérifiable : un petit modèle de langage local (Qwen3.5-0.8B, ~0,8 milliard de paramètres) apprend à résoudre des additions à deux chiffres dont on peut vérifier la réponse par programme — c’est la recette « à la DeepSeek » : group rollouts, avantage relatif au groupe, sans réseau de valeur (value net).

C’est le grain 2 de la série RL Post-Training (#11297) : rlpt_1 a couvert PPO-RLHF sur un monde jouet char-level ; ici on passe à un vrai modèle de langage chargé localement, quantifié en 4 bits pour tenir dans 8 Go de VRAM.

Objectifs

  • comprendre pourquoi GRPO supprime le critic de PPO (avantage par groupe plutôt que par valeur estimée) ;
  • voir un run réel d’entraînement sur GPU 8 Go : VRAM pic, time/step, courbe de reward ;
  • mesurer la reproductibilité multi-seed (convention ≥ 4 seeds) ;
  • formuler un verdict de viabilité pour la série (ce que GRPO apporte à un petit modèle, et ses limites). ## Frontière avec GenAI/PostTraining

Le pendant SOTA 2025 – trl.GRPOTrainer + Qwen3.5-0.8B QLoRA 4-bit + Z3 CSP arithmétique + rewardspy en ligne + multi-seed – vit dans PT-11a et PT-11b. Ce notebook-ci pose à la main la boucle interne (group rollouts, avantage intra-groupe, no critic), sans la pile trl complète ; PT-11a/b montrent le déploiement outillé. Voir #11460 pour la justification.

1. Le problème : une récompense vérifiable, pas un jugement

L’arithmétique a un avantage pédagogique rare en RL : la récompense est vérifiable par programme — on compare la réponse générée à la vérité terrain, sans modèle de récompense appris. C’est exactement le cadre de DeepSeekMath : une fonction de reward déterministe et sans ambiguïté, ce qui élimine le reward hacking dans sa forme la plus grossière.

La tâche : des soustractions a - b (résultat positif garanti). Mesurée au préalable sur ce modèle, elle occupe la zone utile pour GRPO : le modèle répond juste ~88% du temps en greedy, mais seulement ~30-70% en échantillonnage — il y a de la marge à gagner en concentrant la distribution vers les réponses correctes qu’il sait déjà produire.

Le piège du format conversationnel. TRL n’applique le template de chat que si le prompt est une liste de messages ([{"role": "user", "content": ...}]). Avec un prompt brut (str), le modèle reçoit du texte libre sans balises de chat : il « continue » le texte au lieu de répondre — blabla interminable, jamais de token de fin, et un parse qui échoue. C’est la première cause de « GRPO n’apprend rien » sur un petit modèle local : vérifier completions/clipped_ratio dans les logs du run (0 = sain, 1 = le piège).

# Environnement : versions et GPU (l'env conda coursia-ml-training porte torch cu124)
import os, warnings, random, json, re, time
warnings.filterwarnings("ignore")
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
os.environ["TOKENIZERS_PARALLELISM"] = "false"

import torch
import transformers, trl
from datasets import Dataset

print(f"torch        : {torch.__version__} (cuda {torch.version.cuda})")
print(f"transformers : {transformers.__version__}")
print(f"trl          : {trl.__version__}")
print(f"GPU          : {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'CPU'}")
print(f"VRAM totale  : {torch.cuda.get_device_properties(0).total_memory/1e9:.2f} Go" if torch.cuda.is_available() else "")
torch        : 2.6.0+cu124 (cuda 12.4)
transformers : 5.15.0
trl          : 1.9.2
GPU          : NVIDIA GeForce RTX 3070 Laptop GPU
VRAM totale  : 8.59 Go

Lecture chiffree — le socle materiel du notebook. torch 2.6.0+cu124 (cuda 12.4), GPU : NVIDIA GeForce RTX 3070 Laptop GPU, VRAM totale : 8.59 Go. Tout ce qui suit est calibre sur ces 8.59 Go : un vrai modele de langage (753 millions de parametres, compte exact ci-dessous) tient sur un laptop GPU parce que la quantification 4-bit et l’adaptateur LoRA bornent ce qui entre en VRAM. A retenir en lisant les sorties suivantes : le pic d’entrainement mesure plus bas sera 1.58 Go — la contrainte dure du notebook n’est pas la memoire mais le temps (quelques minutes par run).

# Dataset : soustractions (zone GRPO mesuree : greedy ~0.88, sampling ~0.3-0.7)
# Format CONVERSATIONNEL obligatoire : TRL n'applique le chat template que sur une liste de messages
N_PROMPTS = 48
random.seed(42)
pairs = [(random.randint(30, 99), random.randint(10, 29)) for _ in range(N_PROMPTS)]
prompts_conv = [[{"role": "user", "content": f"What is {a} - {b}? Answer with just the number."}]
                for a, b in pairs]
ds = Dataset.from_dict({
    "prompt": prompts_conv,
    "ground_truth": [str(a - b) for a, b in pairs],
})
print(f"dataset: {N_PROMPTS} prompts, exemples:")
for i in range(3):
    print(f"  {ds['prompt'][i][0]['content']}  ->  {ds['ground_truth'][i]}")

def _text(comp):
    # TRL 1.9 : chaque element de completions est un str OU une liste de dicts [{"content": ...}]
    if isinstance(comp, str):
        return comp
    return comp[-1]["content"] if comp else ""

def parse_number(completion: str):
    # Le resultat est le DERNIER nombre emis (le modele peut reciter le calcul avant le resultat)
    ms = re.findall(r"(\d+)", completion.replace(",", ""))
    return ms[-1] if ms else None

def reward_fn(prompts, completions, **kwargs):
    # Les colonnes du dataset arrivent dans kwargs sous leur nom exact
    gts = kwargs.get("ground_truth")
    rs = []
    for c, gt in zip(completions, gts):
        pred = parse_number(_text(c))
        rs.append(1.0 if pred == gt else 0.0)
    return rs

# Test rapide de la reward fn (format TRL : liste plate de str ou de listes de dicts)
print("reward test:", reward_fn(["x"], ["47 - 12 = 35"], ground_truth=["35"]),
      reward_fn(["x"], ["the result of 47 - 12 is 35"], ground_truth=["35"]),
      reward_fn(["x"], [[{"content": "35"}], [{"content": "36"}]], ground_truth=["35", "35"]))
dataset: 48 prompts, exemples:
  What is 44 - 10? Answer with just the number.  ->  34
  What is 65 - 17? Answer with just the number.  ->  48
  What is 58 - 14? Answer with just the number.  ->  44
reward test: [1.0] [1.0] [1.0, 0.0]

2. Le modèle : Qwen3.5-0.8B en QLoRA 4-bit

On charge le modèle local (causal LM, tête texte) en 4 bits NF4 (BitsAndBytesConfig) et on n’entraîne qu’une adaptateur LoRA sur les projections Q/K/V/O : les poids de base restent gelés et quantifiés. C’est ce qui fait tenir le run dans 8 Go — et c’est aussi la configuration industrielle du post-training efficace. Rappel série : learning rate ≤ 2e-5 sur QLoRA (le collapse apparaît dès 5e-5, mesuré sur cette même série).

# Chargement 4-bit + LoRA (pattern valide sur cette machine : 8 Go de VRAM)
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
MODEL = os.path.expanduser("~/models/qwen35-0.8b")
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
                         bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
tok = AutoTokenizer.from_pretrained(MODEL)
if tok.pad_token is None:
    tok.pad_token = tok.eos_token
model = AutoModelForCausalLM.from_pretrained(MODEL, quantization_config=bnb, device_map="auto")

from peft import LoraConfig, get_peft_model
lora = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, bias="none",
                  target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])
model = get_peft_model(model, lora)
model.print_trainable_parameters()
print(f"VRAM apres chargement (LoRA): {torch.cuda.memory_allocated()/1e9:.2f} Go")
trainable params: 540,672 || all params: 752,933,696 || trainable%: 0.0718
VRAM apres chargement (LoRA): 0.78 Go

Lecture chiffree — ce que LoRA entraine vraiment. trainable params: 540,672 || all params: 752,933,696 || trainable%: 0.0718. Le pourcentage imprime se rederive exactement : 540 672 / 752 933 696 = 0,000718, soit 0.0718 % au chiffre imprime pres. Autrement dit : 99,93 % des poids restent gels et quantifies, seules les projections LoRA bougent. Le compte total confirme l’ordre de grandeur annonce en tete de notebook (753 millions de parametres pour le “~0,8 milliard”). Et la memoire suit : VRAM apres chargement (LoRA): 0.78 Go — un peu moins d’un dixieme de la carte pour le modele entier en 4-bit.

# Baseline avant RL : que sait deja faire le modele fige ?
# (greedy = reponse deterministe ; sampling t=1.0 = la distribution que GRPO va explorer)
def _gen(model, prompt, temp):
    # prompt peut etre un str OU une liste de messages (format conversationnel du dataset)
    msgs = prompt if isinstance(prompt, list) else [{"role": "user", "content": prompt}]
    enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to("cuda")
    do_sample = temp > 0
    kw = dict(max_new_tokens=32, do_sample=do_sample, temperature=temp if do_sample else None,
              pad_token_id=tok.pad_token_id, eos_token_id=tok.eos_token_id)
    out = model.generate(**enc, **kw)
    return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True)

def baseline_acc(model, ds, n=16, temp=0.0):
    model.eval()
    ok = 0; samples = []
    with torch.no_grad():
        for i in range(n):
            comp = _gen(model, ds["prompt"][i], temp)
            ok += parse_number(comp) == ds["ground_truth"][i]
            if i < 4:
                samples.append((ds["ground_truth"][i], comp[:70]))
    return ok / n, samples

acc_g, s_g = baseline_acc(model, ds, n=16, temp=0.0)
acc_s, s_s = baseline_acc(model, ds, n=16, temp=1.0)
print(f"Baseline greedy      : accuracy {acc_g:.2f}  ({16} prompts)")
print(f"Baseline sampling    : accuracy {acc_s:.2f}  (distribution exploree par GRPO)")
print("  echantillon greedy :", s_g)
print("  echantillon sample :", s_s)
Baseline greedy      : accuracy 0.88  (16 prompts)
Baseline sampling    : accuracy 0.50  (distribution exploree par GRPO)
  echantillon greedy : [('34', '34'), ('48', '48'), ('44', '44'), ('16', '16')]
  echantillon sample : [('34', '34'), ('48', '38'), ('44', '44'), ('16', '0\n\n6')]

3. GRPO : l’avantage par groupe, pas de value net

PPO maximise \(J(\theta)\) avec un critic qui estime la valeur \(V(s)\) — il faut donc entraîner un second réseau. GRPO (Shao et al. 2024) supprime le critic : pour chaque prompt, on échantillonne un groupe de \(G\) réponses, on calcule la récompense de chacune, et l’avantage est la récompense normalisée au sein du groupe :

\[A_i = \frac{r_i - \mathrm{mean}(r_1..r_G)}{\mathrm{std}(r_1..r_G)}\]

Le modèle n’apprend pas à estimer une valeur : il apprend à augmenter la probabilité des réponses qui surpassent la moyenne du groupe, et à diminuer les autres. Le coefficient \(\beta\) devant la KL (contrainte de proximité avec la politique de référence) est ici mis à 0.0 — la recette DAPO — pour laisser le gradient libre ; sur un cas aussi simple, la KL n’est pas nécessaire pour rester près de la référence.

Config du run : num_generations=4 (groupe de 4 par prompt), per_device_train_batch_size=4 (doit être divisible par G), max_completion_length=48, learning_rate=5e-6 (QLoRA conservateur), budget 40 steps borné — le run complet tient en ~10 minutes sur cette GPU.

# GRPOConfig : budget borne, DAPO (beta=0.0), sans valeur de seed pour l'instant
from trl import GRPOConfig, GRPOTrainer
SEED = 42
torch.manual_seed(SEED)
cfg = GRPOConfig(
    num_generations=4,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=1,
    max_completion_length=48,
    max_steps=40,
    learning_rate=5e-6,
    beta=0.0,
    logging_steps=5,
    output_dir="/tmp/rlpt2_nb",
    save_strategy="no",
    report_to=[],
    bf16=True,
    seed=SEED,
)
trainer = GRPOTrainer(model=model, args=cfg, processing_class=tok,
                      train_dataset=ds, reward_funcs=[reward_fn])
print("trainer pret")
trainer pret
# Run reel : 40 steps GRPO sur GPU 8 Go (time/step + VRAM pic)
torch.cuda.reset_peak_memory_stats()
t0 = time.time()
res = trainer.train()
elapsed = time.time() - t0
n_steps = int(res.global_step)
print(f"\nTRAIN_DONE {elapsed/60:.1f} min ({elapsed/n_steps:.1f} s/step), global_step={n_steps}")
print(f"VRAM pic (training): {torch.cuda.max_memory_allocated()/1e9:.2f} Go")
{'loss': '-0.01332', 'grad_norm': '0', 'learning_rate': '4.5e-06', 'num_tokens': '620', 'completions/mean_length': '3', 'completions/min_length': '2.8', 'completions/max_length': '3.2', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '3', 'completions/min_terminated_length': '2.8', 'completions/max_terminated_length': '3.2', 'rewards/reward_fn/mean': '0.45', 'rewards/reward_fn/std': '0.4464', 'reward': '0.45', 'reward_std': '0.4464', 'frac_reward_zero_std': '0.2', 'entropy': '0.5331', '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': '3.838', 'epoch': '0.1042'}
{'loss': '-7.947e-09', 'grad_norm': '5.281', 'learning_rate': '3.875e-06', 'num_tokens': '1238', 'completions/mean_length': '2.9', 'completions/min_length': '2.8', 'completions/max_length': '3', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.9', 'completions/min_terminated_length': '2.8', 'completions/max_terminated_length': '3', 'rewards/reward_fn/mean': '0.4', 'rewards/reward_fn/std': '0.3155', 'reward': '0.4', 'reward_std': '0.3155', 'frac_reward_zero_std': '0.4', 'entropy': '0.6882', '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': '3.917', 'epoch': '0.2083'}
{'loss': '-0.03766', 'grad_norm': '6.562', 'learning_rate': '3.25e-06', 'num_tokens': '1851', 'completions/mean_length': '2.65', 'completions/min_length': '2.2', 'completions/max_length': '3', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.65', 'completions/min_terminated_length': '2.2', 'completions/max_terminated_length': '3', 'rewards/reward_fn/mean': '0.55', 'rewards/reward_fn/std': '0.5309', 'reward': '0.55', 'reward_std': '0.5309', 'frac_reward_zero_std': '0', 'entropy': '0.75', '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': '3.787', 'epoch': '0.3125'}
{'loss': '-0.06968', 'grad_norm': '9.875', 'learning_rate': '2.625e-06', 'num_tokens': '2465', 'completions/mean_length': '2.7', 'completions/min_length': '2.2', 'completions/max_length': '3', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.7', 'completions/min_terminated_length': '2.2', 'completions/max_terminated_length': '3', 'rewards/reward_fn/mean': '0.3', 'rewards/reward_fn/std': '0.4', 'reward': '0.3', 'reward_std': '0.4', 'frac_reward_zero_std': '0.2', 'entropy': '0.6208', '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': '3.185', 'epoch': '0.4167'}
{'loss': '-0.05038', 'grad_norm': '10.38', 'learning_rate': '2e-06', 'num_tokens': '3081', 'completions/mean_length': '2.8', 'completions/min_length': '2.4', 'completions/max_length': '3', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.8', 'completions/min_terminated_length': '2.4', 'completions/max_terminated_length': '3', 'rewards/reward_fn/mean': '0.3', 'rewards/reward_fn/std': '0.4309', 'reward': '0.3', 'reward_std': '0.4309', 'frac_reward_zero_std': '0.2', 'entropy': '0.6605', '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': '3.885', 'epoch': '0.5208'}
{'loss': '-1.987e-09', 'grad_norm': '11', 'learning_rate': '1.375e-06', 'num_tokens': '3699', 'completions/mean_length': '2.9', 'completions/min_length': '2.8', 'completions/max_length': '3', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.9', 'completions/min_terminated_length': '2.8', 'completions/max_terminated_length': '3', 'rewards/reward_fn/mean': '0.35', 'rewards/reward_fn/std': '0.2155', 'reward': '0.35', 'reward_std': '0.2155', 'frac_reward_zero_std': '0.6', 'entropy': '0.4328', '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': '4.186', 'epoch': '0.625'}
{'loss': '0.1373', 'grad_norm': '2.25', 'learning_rate': '7.5e-07', 'num_tokens': '4365', 'completions/mean_length': '5.3', 'completions/min_length': '3', 'completions/max_length': '12', 'completions/clipped_ratio': '0.05', 'completions/mean_terminated_length': '3.067', 'completions/min_terminated_length': '3', 'completions/max_terminated_length': '3.2', 'rewards/reward_fn/mean': '0.55', 'rewards/reward_fn/std': '0.4464', 'reward': '0.55', 'reward_std': '0.4464', 'frac_reward_zero_std': '0.2', 'entropy': '0.7181', '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': '6.079', 'epoch': '0.7292'}
{'loss': '0.02307', 'grad_norm': '0', 'learning_rate': '1.25e-07', 'num_tokens': '4986', 'completions/mean_length': '3.05', 'completions/min_length': '3', 'completions/max_length': '3.2', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '3.05', 'completions/min_terminated_length': '3', 'completions/max_terminated_length': '3.2', 'rewards/reward_fn/mean': '0.7', 'rewards/reward_fn/std': '0.4', 'reward': '0.7', 'reward_std': '0.4', 'frac_reward_zero_std': '0.2', 'entropy': '0.4484', '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': '3.402', 'epoch': '0.8333'}
{'train_runtime': '162.2', 'train_samples_per_second': '0.986', 'train_steps_per_second': '0.247', 'train_loss': '-0.001329', 'epoch': '0.8333'}

TRAIN_DONE 2.7 min (4.1 s/step), global_step=40
VRAM pic (training): 1.58 Go

Lecture chiffree — les colonnes des logs que la prose ne cite pas. Les steps s’impriment tous les 5 (8 logs pour 40 steps), trois series y meritent l’oeil. (1) frac_reward_zero_std : 0.2, 0.4, 0, 0.2, 0.2, 0.6, 0.2, 0.2 — la fraction de groupes OU TOUTES les recompenses sont egales, donc avantage nul, step perdue pour l’apprentissage : jusqu’a 60 % des groupes sans signal au step 30 (6e log). C’est le “group no-signal” que la mise en regard ci-dessous discute, ici en fraction mesurable. (2) learning_rate : 4.5e-06, 3.875e-06, 3.25e-06, 2.625e-06, 2e-06, 1.375e-06, 7.5e-07, 1.25e-07 — decroissance lineaire exacte jusqu’a presque zero, le schedule du budget epuise. (3) step_time : 3.2 a 6.1 s selon les steps — la pointe a 6.079 s coincide avec la seule completion longue du run (completions/max_length: 12, ailleurs 3-3.2), le tout resume par TRAIN_DONE 2.7 min (4.1 s/step).

# Mesures post-RL : reward final, accuracy greedy, courbe d'apprentissage
# NB : res.metrics ne contient PAS le reward final (uniquement train_runtime/loss) —
# la vraie valeur vit dans trainer.state.log_history (dernier log "rewards/reward_fn/mean").
hist = [h for h in trainer.state.log_history if "rewards/reward_fn/mean" in h]
steps = [h["step"] for h in hist]
rews = [h["rewards/reward_fn/mean"] for h in hist]
mean_rew = rews[-1] if rews else 0.0
acc_g2, s_g2 = baseline_acc(model, ds, n=16, temp=0.0)
acc_s2, _ = baseline_acc(model, ds, n=16, temp=1.0)
print(f"Reward moyen final (dernier log)   : {mean_rew:.3f}")
print(f"Accuracy post-RL (greedy)          : {acc_g2:.2f}")
print(f"Accuracy post-RL (sampling t=1.0)  : {acc_s2:.2f}")
print("  echantillon greedy post-RL :", s_g2)

print("\ncourbe (step, reward moyen du groupe):")
for s_, r_ in zip(steps, rews):
    print(f"  step {s_:>3} : {r_:.3f}")
Reward moyen final (dernier log)   : 0.700
Accuracy post-RL (greedy)          : 0.88
Accuracy post-RL (sampling t=1.0)  : 0.62
  echantillon greedy post-RL : [('34', '34'), ('48', '48'), ('44', '44'), ('16', '16')]

courbe (step, reward moyen du groupe):
  step   5 : 0.450
  step  10 : 0.400
  step  15 : 0.550
  step  20 : 0.300
  step  25 : 0.300
  step  30 : 0.350
  step  35 : 0.550
  step  40 : 0.700

Lecture chiffree — la courbe de reward en dents de scie. La serie imprimee : 0.450, 0.400, 0.550, 0.300, 0.300, 0.350, 0.550, 0.700. Le creux double aux steps 20-25 AVANT la montee finale — la moyenne de groupe ne monte pas en pente douce, elle touche son minimum apres la moitie du run, puis plus que double (0.300 -> 0.700, soit 2,3x). Et la mesure d’accuracy distingue deux axes que la lecture suivante croise : Accuracy post-RL (greedy) : 0.88 (inchange, le mode deterministe etait deja acquis) contre Accuracy post-RL (sampling t=1.0) : 0.62 — la distribution echantillonnee, celle que GRPO explore et note, gagne 0.50 -> 0.62 (+24 % relatifs), pendant que le reward moyen de groupe finit a 0.700.

Lecture du résultat

La lecture honnête de ce run a deux étages. En greedy, le modèle était déjà bon : 0.88 avant RL, 0.88 après — GRPO n’a rien créé, la réponse déterministe était déjà la bonne. En échantillonnage (la distribution que GRPO explore et note), la politique s’est concentrée : le reward moyen du groupe monte jusqu’à 0.700 sur la courbe, là où la baseline sampling partait de 0.50. C’est l’effet GRPO sur une tâche quasi saturée : les réponses correctes que le modèle savait produire deviennent plus probables, les erreurs d’échantillonnage sont pénalisées par le groupe.

Deux marqueurs de santé dans les logs du run : completions/mean_length ~3 tokens (le modèle répond court et s’arrête — le format conversationnel fait son travail) et un grad_norm non nul sur les steps où le groupe a de la variance. Le run tient les contraintes de la série : 1.58 de VRAM pic sur 8 Go, 4.1 par step.

Mise en regard : « GRPO n’a rien créé » ↔︎ « group no-signal / easy stage unlearnable » (JohnEnev Part 3)

Notre verdict local — « GRPO ne crée rien, il concentre la distribution sampling vers ce que la politique savait déjà faire (greedy 0.88 → 0.88 ; reward sampling 0.50 → 0.700) » — est corroboré par une observation structurellement identique rapportée par JohnEnev, série Substack “modern-llm” Part 3 (21 juillet 2026) sur son V2 (315M).

JohnEnev identifie deux modes d’échec GRPO qui résument exactement ce qu’on observe en local :

Mode d’échec JohnEnev Part 3 (rapporté) Local (notre rlpt_2)
« group no-signal » — std=0 intra-groupe → avantage nul → gradient nul frac_reward_zero_std monte à 0.6 (step 30) → G=4 quasi-ex-æquo
« easy stage unlearnable » — stage trop facile → reward ≈ 1 pour tous → pas de signal non plus baseline greedy 0.88 avant RL = déjà saturée → GRPO ne peut qu’amplifier, pas créer

Le premier (« no-signal ») est ce qui se passe quand le groupe ne contient aucune diversité : tous les prompts reçoivent la même récompense → le centrage-réduction donne A_i = 0 partout → le gradient est nul. Notre mesure frac_reward_zero_std = 0.6 au step 30 montre que 60 % des groupes ont une variance nulle — donc 60 % des updates ne portent aucun signal. C’est exactement la pathologie décrite par JohnEnev.

Le second (« easy stage unlearnable ») est ce qui se passe quand tous les prompts sont déjà résolus par la politique de départ. Notre baseline greedy 0.88 sur les soustractions à 2 chiffres = la policy SFT (Qwen3.5-0.8B généraliste) sait déjà produire la bonne réponse en mode déterministe. GRPO ne peut alors que réduire la variance d’échantillonnage (0.50 → 0.700 sur sampling t=1.0), pas augmenter la qualité moyenne (greedy reste à 0.88). C’est l’effet GRPO sur tâche quasi-saturée.

Convergence des deux diagnostics

JohnEnev propose la règle opérationnelle : GRPO apprend seulement sur les prompts où le groupe est divisé (ni triviaux, ni impossibles). Notre mesure en est l’illustration directe :

  • Trivial (greedy 0.88) → GRPO concentre, n’invente pas (notre cas)
  • Impossible (addition à 3 termes, baseline 0.42) → signal trop rare, GRPO meurt (cf exercice 1 du notebook — « exploration morte » est la formulation que donne la cellule §6 du même notebook)
  • Zone GRPO (greedy ~0.5, sampling ~0.3) → GRPO apprend vraiment (configuration type PT-04 / PT-11 sur tâches ciblées)

Le takeaway opérationnel

Quand la tâche est triviale pour la policy de départ, GRPO n’a rien à faire. Quand elle est hors de portée, GRPO n’a pas de signal. La zone GRPO — où la policy fait parfois juste, parfois non — est étroite, et c’est là que GRPO est rentable.

C’est le miroir exact de la règle « SFT d’abord, RL ensuite » formulée en Part 3 : GRPO n’est rentable que si la policy de départ est déjà dans la zone de transition. Notre soustraction 2-chiffres est trop facile (88 % greedy) ; le notebook PT-11b sur GSM8K est trop dur (reward ~0.14, cf cellule §10). La zone utile — celle où GRPO crée réellement — reste à identifier empiriquement pour chaque tâche.

4. Reproductibilité : la variance inter-seeds

Un seul run peut masquer un coup de chance. La convention de la série est ≥ 4 seeds : on relance le même entraînement court (12 steps) sur les seeds 0, 1, 7 et 42, et on regarde la dispersion de l’accuracy finale. C’est ce qui distingue une amélioration structurelle d’un effet de tirage.

# Multi-seed : 4 runs courts (12 steps chacun) pour mesurer la variance
# IMPORTANT : chaque seed part d'un modele de base RECARGE (poids LoRA reinitialises) —
# sinon on mesurerait la variance du fine-tuning continué, pas celle de l'algorithme.
import gc

def load_fresh():
    m = AutoModelForCausalLM.from_pretrained(MODEL, quantization_config=bnb, device_map="auto")
    m = get_peft_model(m, lora)
    return m

SEEDS = [0, 1, 7, 42]
MULTI_STEPS = 12
accs = {}; rews = {}
for s in SEEDS:
    model_s = load_fresh()
    cfg_s = GRPOConfig(
        num_generations=4, per_device_train_batch_size=4, gradient_accumulation_steps=1,
        max_completion_length=48, max_steps=MULTI_STEPS, learning_rate=5e-6, beta=0.0,
        logging_steps=MULTI_STEPS, output_dir=f"/tmp/rlpt2_nb_s{s}",
        save_strategy="no", report_to=[], bf16=True, seed=s,
    )
    tr_s = GRPOTrainer(model=model_s, args=cfg_s, processing_class=tok,
                       train_dataset=ds, reward_funcs=[reward_fn])
    r_s = tr_s.train()
    # le reward final vit dans log_history (res.metrics ne le porte pas, cf section 3)
    hist_s = [h for h in tr_s.state.log_history if "rewards/reward_fn/mean" in h]
    rews[s] = float(hist_s[-1]["rewards/reward_fn/mean"]) if hist_s else 0.0
    acc_s2, _ = baseline_acc(model_s, ds, n=16, temp=0.0)
    accs[s] = acc_s2
    print(f"seed {s:>2} : reward moyen {rews[s]:.3f} | accuracy greedy {acc_s2:.2f}")
    del model_s, tr_s
    gc.collect(); torch.cuda.empty_cache()

import statistics
acc_vals = list(accs.values())
print(f"\naccuracy inter-seeds: mean {statistics.mean(acc_vals):.2f}, std {statistics.stdev(acc_vals):.2f}")
print(f"  min {min(acc_vals):.2f}, max {max(acc_vals):.2f}")
{'loss': '-0.01035', 'grad_norm': '7.094', 'learning_rate': '4.167e-07', 'num_tokens': '1487', 'completions/mean_length': '2.979', 'completions/min_length': '2.833', 'completions/max_length': '3.083', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.979', 'completions/min_terminated_length': '2.833', 'completions/max_terminated_length': '3.083', 'rewards/reward_fn/mean': '0.6042', 'rewards/reward_fn/std': '0.3046', 'reward': '0.6042', 'reward_std': '0.3046', 'frac_reward_zero_std': '0.4167', 'entropy': '0.5419', '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': '3.917', 'epoch': '0.25'}
{'train_runtime': '47.37', 'train_samples_per_second': '1.013', 'train_steps_per_second': '0.253', 'train_loss': '-0.01035', 'epoch': '0.25'}
seed  0 : reward moyen 0.604 | accuracy greedy 0.88
{'loss': '0.008332', 'grad_norm': '21.88', 'learning_rate': '4.167e-07', 'num_tokens': '1487', 'completions/mean_length': '2.979', 'completions/min_length': '2.917', 'completions/max_length': '3.167', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.979', 'completions/min_terminated_length': '2.917', 'completions/max_terminated_length': '3.167', 'rewards/reward_fn/mean': '0.5625', 'rewards/reward_fn/std': '0.3879', 'reward': '0.5625', 'reward_std': '0.3879', 'frac_reward_zero_std': '0.25', 'entropy': '0.5089', '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': '4.078', 'epoch': '0.25'}
{'train_runtime': '49.1', 'train_samples_per_second': '0.978', 'train_steps_per_second': '0.244', 'train_loss': '0.008332', 'epoch': '0.25'}
seed  1 : reward moyen 0.562 | accuracy greedy 0.88
{'loss': '-0.01934', 'grad_norm': '7.219', 'learning_rate': '4.167e-07', 'num_tokens': '1487', 'completions/mean_length': '2.979', 'completions/min_length': '2.667', 'completions/max_length': '3.25', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.979', 'completions/min_terminated_length': '2.667', 'completions/max_terminated_length': '3.25', 'rewards/reward_fn/mean': '0.4792', 'rewards/reward_fn/std': '0.4841', 'reward': '0.4792', 'reward_std': '0.4841', 'frac_reward_zero_std': '0.08333', 'entropy': '0.5547', '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': '3.466', 'epoch': '0.25'}
{'train_runtime': '41.73', 'train_samples_per_second': '1.15', 'train_steps_per_second': '0.288', 'train_loss': '-0.01934', 'epoch': '0.25'}
seed  7 : reward moyen 0.479 | accuracy greedy 0.88
{'loss': '-0.009883', 'grad_norm': '13.56', 'learning_rate': '4.167e-07', 'num_tokens': '1481', 'completions/mean_length': '2.854', 'completions/min_length': '2.667', 'completions/max_length': '3.083', 'completions/clipped_ratio': '0', 'completions/mean_terminated_length': '2.854', 'completions/min_terminated_length': '2.667', 'completions/max_terminated_length': '3.083', 'rewards/reward_fn/mean': '0.4792', 'rewards/reward_fn/std': '0.4489', 'reward': '0.4792', 'reward_std': '0.4489', 'frac_reward_zero_std': '0.1667', 'entropy': '0.6065', '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': '3.796', 'epoch': '0.25'}
{'train_runtime': '45.71', 'train_samples_per_second': '1.05', 'train_steps_per_second': '0.263', 'train_loss': '-0.009883', 'epoch': '0.25'}
seed 42 : reward moyen 0.479 | accuracy greedy 0.88

accuracy inter-seeds: mean 0.88, std 0.00
  min 0.88, max 0.88

Lecture du résultat

Lecture honnête, comme pour le run principal : à 12 steps et lr 5e-6, l’accuracy greedy ne bouge pas — les 4 seeds restent à 0.88 ± 0.00, exactement la baseline (le pic déterministe était déjà acquis). Ce qui varie entre seeds, c’est le reward échantillonné final : de 0.479 à 0.604 selon le tirage, contre 0.50 en baseline sampling — deux seeds nettement au-dessus, deux juste en dessous. Le run principal (40 steps, 0.700) est plus haut parce qu’il a plus d’itérations de concentration, pas parce qu’un seed particulier aurait de la chance.

C’est le comportement qu’on attend d’un post-training honnête : le verdict multi-seed, pas le meilleur run isolé, est ce qui se compare d’un notebook à l’autre de la série.

5. Exercices

Trois exercices pour ancrer la mécanique. Chacun reprend une brique du pipeline : la récompense, le parsing, et le paramètre de groupe. Complétez les stubs, puis exécutez.

Exercice 1 : la tâche difficile — addition à trois termes

La soustraction était dans la zone GRPO (le modèle savait déjà répondre juste en greedy). L’addition à trois termes a + b + c ne l’est pas : mesurée au préalable, la baseline greedy tombe à ~0.42 et l’échantillonnage à ~0.17 — le signal d’avantage est rare. Construisez le dataset et observez ce que GRPO fait (ou ne fait pas) quand la politique initiale échoue presque toujours : c’est la frontière de l’exploration morte.

# TODO etudiant : dataset d'additions a 3 termes + run GRPO court.
# 1. pairs_3 = [(random.randint(10, 49), random.randint(10, 49), random.randint(10, 49)) for ...]
# 2. prompts au format CONVERSATIONNEL [[{"role": "user", "content": f"What is {a} + {b} + {c}? ..."}]]
#    (rappel : un prompt str nu casse le chat template -> blabla sans fin -> reward ~0)
# 3. ground_truth = str(a + b + c) ; reutiliser reward_fn tel quel.
# 4. 12 steps de GRPO (seed 0) puis comparer baseline_acc avant/après et la courbe de reward.
def build_add3_dataset(n=16, seed=7):
    random.seed(seed)
    # TODO etudiant
    return None

print("Exercice a completer (voir enonce)")
Exercice a completer (voir enonce)

Exercice 2 : une récompense partielle de format

La récompense binaire (0/1) n’encourage pas le format : une réponse the sum of 17 and 25 is 42 obtient 1.0 même si elle viole la consigne « just the number ». Implémentez reward_format qui donne 0.5 quand le nombre est correct mais la réponse verbeuse (plus de ~6 tokens), 1.0 quand le nombre est correct et court, 0.0 sinon. Observez l’effet sur les échantillons post-RL.

# TODO etudiant : reward partiel de format (utiliser tok pour compter les tokens).
def reward_format(prompts, completions, **kwargs):
    gts = kwargs.get("ground_truth")
    rs = []
    for c, gt in zip(completions, gts):
        text = _text(c)
        pred = parse_number(text)
        # TODO etudiant : n_tok = len(tok.encode(text))  ; attribuer 1.0 / 0.5 / 0.0
        rs.append(0.0)
    return rs

print("Exercice a completer (voir enonce)")
Exercice a completer (voir enonce)

Exercice 3 : l’effet de la taille du groupe

GRPO normalise l’avantage au sein du groupe : plus le groupe est grand, plus la moyenne de référence est fiable, mais plus chaque step coûte cher en générations. Relancez 12 steps avec num_generations=8 (per_device_train_batch_size doit alors être divisible par 8) et comparez le reward moyen final au run à num_generations=4. Le gain vaut-il le double de temps de génération ?

# TODO etudiant : GRPO avec num_generations=8 (12 steps, seed 0), puis comparer rews.
cfg8 = GRPOConfig(
    num_generations=8, per_device_train_batch_size=8, gradient_accumulation_steps=1,
    max_completion_length=48, max_steps=12, learning_rate=5e-6, beta=0.0,
    logging_steps=12, output_dir="/tmp/rlpt2_nb_g8",
    save_strategy="no", report_to=[], bf16=True, seed=0,
)
# TODO etudiant : GRPOTrainer(model=model, args=cfg8, processing_class=tok,
#                             train_dataset=ds, reward_funcs=[reward_fn]) puis .train()
# puis comparer rewards/reward_fn/mean au run G=4 de la section 4.
print("Exercice a completer (voir enonce)")
Exercice a completer (voir enonce)

6. Conclusion : verdict de viabilité pour la série

Ce notebook a établi le socle GRPO de la série sur un vrai modèle local :

  • la mécanique est saine : complétions propres (~3 tokens, terminées), signal d’avantage réel dans les groupes, reward qui monte (0.700 final vs 0.50 en baseline sampling), comportement stable sur 4 seeds (greedy inchangé à 0.88 ± 0.00, reward échantillonné final 0.48-0.60) ;
  • les contraintes 8 Go tiennent avec une marge énorme : 1.58 de pic VRAM, ~4.1/step — un run de 40 steps prend ~3 minutes : la série peut monter à 100-200 steps par notebook sans sortir de la fenêtre GPU, ou élargir les groupes (num_generations) ;
  • la leçon structurelle : GRPO ne crée pas de capacité — greedy avant = greedy après — il concentre la distribution échantillonnée vers ce que la politique savait déjà faire de mieux. Sur une tâche hors de la capacité initiale (exercice 1 : addition à trois termes), le signal disparaît : c’est l’exploration morte, le sujet du grain suivant (reward hacking et limites).

Verdict : GRPO 0.8B QLoRA sur 8 Go est viable pour la série — à condition de (1) des prompts au format conversationnel, (2) une tâche dans la zone 0.3-0.7 de baseline sampling, (3) un parse de reward robuste (dernier nombre émis).

Références : DeepSeekMath (Shao et al., 2024) pour GRPO ; DAPO (Yu et al., 2025) pour le β=0 ; Schulman et al. (2017) pour l’objectif clipé ; série RL Post-Training #11297 pour le contexte (rlpt_1 : PPO-RLHF).

Retour au sommet