# Parameters
BATCH_MODE = "true"PT-04 — Group Relative Policy Optimization (GRPO)
Serie : Post-Training SOTA 2024-2025 (Epic #1742, sub-issue #1757) LIVRABLE CLE de l’Epic #1742 Pre-requis : PT-01 (intro), PT-02 (SFT), PT-03 (DPO) recommandes Objectifs pedagogiques : 1. Comprendre pourquoi GRPO est la technique cle derriere Deepseek-R1 (janvier 2025) 2. Maitriser la formule de l’avantage relatif intra-group (normalisation sans critic) 3. Implementer trl.GRPOTrainer avec une reward function personnalisable 4. Explorer l’impact du group size sur la variance et la memoire 5. Evaluer qualitativement les outputs avant/après GRPO sur des prompts reasoning
References cles : - Shao et al., “DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models” (2024) — paper origin GRPO - Deepseek-AI, “DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning” (2025) - HuggingFace TRL GRPOTrainer integration docs (2025)
1. Pourquoi GRPO a revolutionne le post-training (janvier 2025)
Le choc Deepseek-R1
En janvier 2025, Deepseek-AI publie Deepseek-R1, un modèle de raisonnement qui rivalise avec OpenAI o1 sans données de raisonnement humain. La cle : un entrainement RL pure avec GRPO, qui a fait emerger des comportements de “chain-of-thought” spontanes.
Timeline :
| Date | Événement | Impact |
|---|---|---|
| 2024-02 | DeepSeekMath (GRPO origin) | GRPO pour raisonnement mathematique |
| 2024-12 | Deepseek-V3 (MTP, MoE) | Architecture de base |
| 2025-01 | Deepseek-R1 | GRPO → reasoning emergent sans SFT |
| 2025-02 | R1-Distill (1.5B→70B) | Modèles distilles a partir de R1 |
| 2025-Q1 | GRPO → TRL integration | Democratise via HuggingFace |
Ce que GRPO a de unique
Pas de critic network. En RL classique (PPO), on entraine un reseau separé (critic/value function) pour estimer l’avantage. GRPO elimine completement cette composante :
| Composant | PPO classique | GRPO |
|---|---|---|
| Policy network | Oui | Oui |
| Reference model | Oui (KL penalty) | Oui (KL via beta) |
| Critic/Value function | Oui | NON |
| Reward model | Oui (appris) | Oui ou fonction exacte |
| Memoire totale | ~4x le modèle | ~2x le modèle |
Reduction memoire : ~50% vs PPO. C’est ce qui rend GRPO praticable sur GPU 8 Go.
2. Mathematiques du GRPO — l’avantage relatif intra-group
L’idee centrale
Pour un prompt \(x\), GRPO echantillonne G completions depuis la policy : \(\{y_1, y_2, ..., y_G\}\).
Chaque completion recoit un reward \(R_i\) (via reward model ou fonction exacte).
L’avantage est calcule par normalisation intra-group :
\[\hat{A}_i = \frac{R_i - \mu_G}{\sigma_G}\]
ou \(\mu_G = \frac{1}{G}\sum_{j=1}^{G} R_j\) et \(\sigma_G = \sqrt{\frac{1}{G}\sum_{j=1}^{G}(R_j - \mu_G)^2}\)
Le loss GRPO
\[\mathcal{L}_{\text{GRPO}}(\theta) = -\mathbb{E}_{x, \{y_i\}_{i=1}^{G}} \left[ \frac{1}{G} \sum_{i=1}^{G} \hat{A}_i \cdot \min\left( \rho_i \cdot r_i(\theta), \text{clip}(\rho_i, 1-\epsilon, 1+\epsilon) \cdot r_i(\theta) \right) - \beta \cdot D_{\text{KL}}(\pi_\theta || \pi_{\text{ref}}) \right]\]
Decomposition :
- \(r_i(\theta) = \frac{\pi_\theta(y_i | x)}{\pi_{\theta_{\text{old}}}(y_i | x)}\) : ratio de probabilite (comme PPO)
- \(\rho_i\) : rapport de log-probabilite
- \(\epsilon\) : clipping parameter (typique 0.2)
- \(\beta\) : coefficient KL (typique 0.04)
- \(G\) : group size (typique 4-16)
Pourquoi la normalisation intra-group fonctionne
- Pas besoin de baseline apprise : la moyenne du groupe sert de baseline naturelle
- Variance reduite : normaliser par \(\sigma_G\) reduit le bruit
- Gradients informatifs : même avec des rewards sparse, le classement relatif fournit un signal
- Scale avec le compute : plus de G = meilleure estimation, mais plus de memoire
Intuition geometric
Imaginez un prompt “Solve : \(2x + 3 = 7\)”. GRPO genere G=4 reponses : - \(y_1\) : “x = 2” → reward 1.0 (correct) - \(y_2\) : “x = 5” → reward 0.0 (incorrect) - \(y_3\) : “x = 2, verification : 2*2+3=7” → reward 1.0 (correct + raisonnement) - \(y_4\) : “je ne sais pas” → reward 0.0 (refusal)
Avantages : \(\mu_G = 0.5\), \(\sigma_G = 0.5\), d’ou \(\hat{A} = [1.0, -1.0, 1.0, -1.0]\). Le modèle apprend a preferer \(y_1\) et \(y_3\), eviter \(y_2\) et \(y_4\).
3. Pseudocode d’une étape GRPO
pour chaque batch de prompts:
# 1. Echantillonner G completions par prompt
pour chaque prompt x:
completions = [sample(pi_theta, x) for _ in range(G)]
# 2. Calculer les rewards
rewards = [reward_function(y) for y in completions]
# 3. Normaliser intra-group (l'avantage GRPO)
mu = mean(rewards)
sigma = std(rewards)
advantages = [(r - mu) / (sigma + eps) for r in rewards]
# 4. Clipped surrogate objective (comme PPO)
ratios = [pi_theta(y|x) / pi_old(y|x) for y in completions]
clipped = clip(ratios, 1-epsilon, 1+epsilon)
policy_loss = -min(ratio * advantage, clipped * advantage)
# 5. KL penalty
kl = D_KL(pi_theta || pi_ref)
# 6. Update
total_loss = policy_loss + beta * kl
total_loss.backward()
optimizer.step()
Complexite : \(O(B \times G \times L)\) ou B=batch size, G=group size, L=sequence length. Memoire : \(O(G \times L)\) par prompt en plus du modèle (stockage des G log-probs).
4. Verification de l’environnement
GRPO echantillonne G completions par prompt, ce qui consomme plus de VRAM que DPO. Avec 4-bit + LoRA + group_size=4, on reste sous 6 Go.
import sys
import os
import platform
print(f"Python : {sys.version}")
print(f"Plateforme : {platform.platform()}")
LOAD_MODEL_AND_TRAIN = True # Migration Qwen3.5 (mandat #10289) : le notebook execute le vrai GRPO sur GPU
print(f"\nLOAD_MODEL_AND_TRAIN = {LOAD_MODEL_AND_TRAIN}")
if not LOAD_MODEL_AND_TRAIN:
print("(Mode CPU-safe : generation + training seront skippees)")Python : 3.12.13 | packaged by Anaconda, Inc. | (main, Mar 19 2026, 20:16:45) [MSC v.1942 64 bit (AMD64)]
Plateforme : Windows-11-10.0.26200-SP0
LOAD_MODEL_AND_TRAIN = True
import torch
CUDA_AVAILABLE = torch.cuda.is_available()
if CUDA_AVAILABLE:
gpu_name = torch.cuda.get_device_name(0)
gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"Accélérateur CUDA détecté : {gpu_name} ({gpu_mem:.1f} Go)")
else:
print("Accélérateur CUDA détecté : False — les cellules d'entraînement GPU seront ignorées")Accélérateur CUDA détecté : False — les cellules d'entraînement GPU seront ignorées
5. Design de la reward function
Contrairement a DPO (qui utilise des paires pre-labelisees), GRPO utilise une reward function callable qui prend une completion et retourne un float.
Pour ce notebook pedagogique, on définit des reward functions simples :
| Reward | Logique | Quand l’utiliser |
|---|---|---|
length_reward |
Penalise les reponses trop courtes ou trop longues | Alignement format |
keyword_reward |
+1 si le mot-cle attendu est present | QA simple |
json_format_reward |
+1 si JSON valide avec cle attendue | Generation structuree |
combined_reward |
Moyenne ponderee des 3 | Usage general |
import json
import re
def length_reward(completion: str, target_min: int = 20, target_max: int = 200) -> float:
# Recompense sweet-spot : penalise trop court (flemme) ET trop long (verbiage).
length = len(completion.strip())
if length < target_min:
return length / target_min * 0.5
elif length > target_max:
return max(0.0, 1.0 - (length - target_max) / target_max)
else:
return 1.0
def keyword_reward(completion: str, keywords: list = None) -> float:
# Recompense si des mots-cles attendus sont presents.
if keywords is None:
keywords = ["therefore", "because", "since", "thus", "hence"]
completion_lower = completion.lower()
found = sum(1 for kw in keywords if kw in completion_lower)
return found / len(keywords)
def json_format_reward(completion: str, required_key: str = "answer") -> float:
# Recompense si la completion est un JSON valide avec cle attendue.
try:
parsed = json.loads(completion.strip())
if isinstance(parsed, dict) and required_key in parsed:
return 1.0
elif isinstance(parsed, dict):
return 0.5
else:
return 0.25
except (json.JSONDecodeError, TypeError):
return 0.0
# ---- Extraction robuste (#12438) : devise, separateurs de milliers, unites ----
# Token numerique tolerant : '$1,234.00', '42 km', '22.2 C', '0.9999999'.
_NUM_TOKEN = r"-?\s*[$£€]?\s*\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\s*[$£€]?\s*\d+(?:\.\d+)?"
def _to_float(token: str):
# Ne garde que chiffres, point, signe : '$1,234.00 km' -> 1234.00
s = re.sub(r"[^0-9.\-]", "", token)
if not s or s in ("-", ".", "-."):
return None
try:
return float(s)
except ValueError:
return None
def extract_answer(completion: str):
# Extrait la derniere valeur numerique en tolerant devise/unites/milliers.
# Priorite : \boxed{}, '####' (GSM8K), 'answer is'/':'/'=', fallback dernier nombre.
boxed = re.findall(r"\\boxed\{([^}]+)\}", completion)
if boxed:
value = _to_float(boxed[-1])
if value is not None:
return value
for pattern in (r"####\s*(" + _NUM_TOKEN + r")",
r"(?:answer is|answer:|=)\s*(" + _NUM_TOKEN + r")"):
found = re.findall(pattern, completion, re.IGNORECASE)
if found:
value = _to_float(found[-1])
if value is not None:
return value
numbers = re.findall(_NUM_TOKEN, completion)
if numbers:
return _to_float(numbers[-1])
return None
def correctness_reward(completion: str, ground_truth: float,
abs_tol: float = 1e-6, rel_tol: float = 1e-3) -> float:
"""Credit partiel (#12438) : 1.0 juste, 0.5 reponse extraite mais fausse,
0.0 rien d'extractible. C'est le trou central que comblera ce reward :
'Therefore, x = 2 because...' sur un probleme dont la reponse est 42
ne touche plus que la moitie heuristique du melange.
"""
predicted = extract_answer(completion)
if predicted is None:
return 0.0
if abs(predicted - ground_truth) <= max(abs_tol, rel_tol * abs(ground_truth)):
return 1.0
return 0.5
# trl 1.9 : les reward funcs recvent des BATCHS (prompts, completions, ...) et
# retournent une liste de scores. Avec un dataset conversationnel, chaque
# completion est une liste de messages [{"role": "assistant", "content": ...}]
# (patron PT-11b, verifie sur trl 1.9.2 grpo_trainer.py ligne 2187).
def combined_reward(prompts: list, completions: list, answer: list = None, **kwargs) -> list:
# Reward combine. Si le dataset porte la colonne `answer` (TRL la passe en
# kwarg alignee sur les completions), la justesse numerique domine le
# melange — sinon degradation honnete vers les heuristiques.
rewards = []
for i, completion in enumerate(completions):
if isinstance(completion, list):
text = completion[-1].get('content', '') if completion else ''
else:
text = str(completion)
r_len = length_reward(text)
r_kw = keyword_reward(text)
if answer is not None and i < len(answer):
r_correct = correctness_reward(text, float(answer[i]))
rewards.append(0.5 * r_correct + 0.2 * r_len + 0.3 * r_kw)
else:
rewards.append(0.4 * r_len + 0.6 * r_kw)
return rewards
# Tests unitaires des reward functions
print("Tests des reward functions :")
print("--- length_reward : bornes visibles (sweet-spot) ---")
print(f" length_reward('short') = {length_reward('short'):.2f} (< target_min=20 : penalise)")
print(f" length_reward('a' * 100) = {length_reward('a' * 100):.2f} (dans [20, 200] : 1.0)")
print(f" length_reward('a' * 250) = {length_reward('a' * 250):.2f} (> target_max=200 : penalise)")
print("--- keyword / format (inchanges) ---")
print(f" keyword_reward('Therefore, x = 2 because...') = {keyword_reward('Therefore, x = 2 because...'):.2f}")
json_test = '{"answer": 42}'
print(f" json_format_reward('{json_test}') = {json_format_reward(json_test):.2f}")
print(f" json_format_reward('not json') = {json_format_reward('not json'):.2f}")
print("--- correctness_reward : cas pieges (#12438) ---")
print(f" '$1,234.00' vs 1234 -> {correctness_reward('The answer is $1,234.00', 1234):.1f} (normalisation devise + milliers)")
print(f" '0.9999999' vs 1 -> {correctness_reward('The answer is 0.9999999', 1):.1f} (tolerance relative 1e-3)")
print(f" '42 km' vs 42 -> {correctness_reward('The answer is 42 km', 42):.1f} (unite toleree)")
print(f" 'x = 2 because' vs 42 -> {correctness_reward('Therefore, x = 2 because it holds', 42):.1f} (extrait mais faux : 0.5)")
print(f" 'Je ne sais pas' vs 42 -> {correctness_reward('Je ne sais pas', 42):.1f} (rien d'extractible : 0.0)")
batch_test = [
[{"role": "assistant", "content": "Therefore the answer is 42 because the equation holds"}],
[{"role": "assistant", "content": "short"}],
]
batch_scores = combined_reward(None, batch_test, answer=[42.0, 42.0])
print(f" combined_reward(batch, answer=[42, 42]) = {batch_scores}")
print()
print("Reward functions pretes.")Tests des reward functions :
--- length_reward : bornes visibles (sweet-spot) ---
length_reward('short') = 0.12 (< target_min=20 : penalise)
length_reward('a' * 100) = 1.00 (dans [20, 200] : 1.0)
length_reward('a' * 250) = 0.75 (> target_max=200 : penalise)
--- keyword / format (inchanges) ---
keyword_reward('Therefore, x = 2 because...') = 0.40
json_format_reward('{"answer": 42}') = 1.00
json_format_reward('not json') = 0.00
--- correctness_reward : cas pieges (#12438) ---
'$1,234.00' vs 1234 -> 1.0 (normalisation devise + milliers)
'0.9999999' vs 1 -> 1.0 (tolerance relative 1e-3)
'42 km' vs 42 -> 1.0 (unite toleree)
'x = 2 because' vs 42 -> 0.5 (extrait mais faux : 0.5)
'Je ne sais pas' vs 42 -> 0.0 (rien d'extractible : 0.0)
combined_reward(batch, answer=[42, 42]) = [0.82, 0.025]
Reward functions pretes.
Exercice 1 : Implementer une reward function de coherence logique
Les reward functions existantes (longueur, mots-cles, JSON) evaluent le format mais pas le fond. L’objectif est d’implementer une reward function coherence_reward qui detecte la presence de structures logiques dans la reponse (sillogismes, cause-effet, enumeration structuree).
Objectif : ecrire une fonction qui analyse une completion et retourne un score de coherence base sur la presence de connecteurs logiques, de numerotation structuree, et de longueur adequat.
Indices : - # Étape 1 : Définir des patterns de connecteurs logiques (“donc”, “par consequent”, “premierement”, “en conclusion”) - # Étape 2 : Compter le nombre de patterns trouves et normaliser par la longueur du texte - # Indice : combiner avec un bonus pour les reponses de longueur intermediaire (ni trop courtes, ni trop longues)
def coherence_reward(completion: str) -> float:
# TODO etudiant : implementer la reward de coherence logique
# Etape 1 : definir les connecteurs logiques
connecteurs = [] # TODO etudiant : liste de mots/phrases logiques
# Etape 2 : compter les occurrences et normaliser
completion_lower = completion.lower()
score = 0.0 # TODO etudiant : calculer le score
return score
print("Exercice a completer : coherence_reward")Exercice a completer : coherence_reward
5b. Harness d’évaluation du raisonnement — les 4 métriques d’une éval R1 crédible
Un reward n’est qu’un signal d’entraînement : il ne dit pas si le modèle raisonne. Une éval de raisonnement crédible (au sens R1) mesure accuracy sur held-out, format_rate (fraction des complétions dont la réponse est extractible au format attendu), think_length (longueur moyenne du raisonnement) et backtrack_rate (fraction des complétions portant une marque d’auto-correction — « wait », « actually », « let me reconsider »… liste explicite dans le code). Ce harness (#12438) tourne avec le même instrument avant et après l’entraînement, et sert de socle à PT-06.
# Harness d'evaluation du raisonnement (#12438) : le meme instrument avant/apres
# l'entrainement, sinon le delta ne veut rien dire (patron de la cellule precedente).
BACKTRACK_MARKERS = [
"wait", "actually", "let me reconsider", "let me double-check",
"on second thought", "hmm", "correction",
]
def reasoning_metrics_on_completions(completions: list, answers: list) -> dict:
"""Calcule les 4 metriques sur des completions DEJA produites.
Self-test CPU (textes de reference) et analyse post-hoc ; la generation,
elle, se fait dans evaluate_reasoning_model.
"""
n = len(completions)
n_fmt = n_acc = n_back = 0
total_len = 0
for comp, gt in zip(completions, answers):
pred = extract_answer(comp)
if pred is not None:
n_fmt += 1
if abs(pred - gt) <= max(1e-6, 1e-3 * abs(gt)):
n_acc += 1
total_len += len(comp.split())
low = comp.lower()
if any(marker in low for marker in BACKTRACK_MARKERS):
n_back += 1
return {
"n_generations": n,
"accuracy": n_acc / n,
"format_rate": n_fmt / n,
"think_length": total_len / n,
"backtrack_rate": n_back / n,
}
def evaluate_reasoning_model(model, tokenizer, problems, answers, n=4,
max_new_tokens=128, temperature=0.1, seed=None) -> dict:
"""Genere n completions par probleme (basse temperature) et retourne les 4 metriques.
C'est la version LLM de la courbe d'apprentissage : meme instrument, meme
echantillon, avant et apres l'entrainement (reutilisable par PT-06).
"""
import torch as _t
if seed is not None:
_t.manual_seed(seed)
model.eval()
completions = []
for pb, gt in zip(problems, answers):
messages = [{"role": "user", "content": pb + "\n\nReason step by step, then end with 'The answer is <number>'."}]
try:
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
except Exception:
text = pb
ids = tokenizer(text, return_tensors="pt").to(model.device)
for _ in range(n):
with _t.no_grad():
out = model.generate(**ids, max_new_tokens=max_new_tokens,
do_sample=temperature > 0,
temperature=temperature or None,
pad_token_id=tokenizer.eos_token_id)
completions.append(tokenizer.decode(out[0][ids["input_ids"].shape[1]:],
skip_special_tokens=True))
return reasoning_metrics_on_completions(
completions, [gt for gt in answers for _ in range(n)]
)
def print_before_after(metrics_before: dict, metrics_after: dict) -> None:
"""Tableau avant/apres des 4 metriques (#12438)."""
cols = ("accuracy", "format_rate", "think_length", "backtrack_rate")
header = f"{'metrique':<15}{'avant':>10}{'apres':>10}{'delta':>10}"
print(header)
print("-" * len(header))
for c in cols:
b, a = metrics_before[c], metrics_after[c]
if "rate" in c or c == "accuracy":
print(f"{c:<15}{100*b:>9.1f}%{100*a:>9.1f}%{100*(a-b):>+9.1f}%")
else:
print(f"{c:<15}{b:>10.1f}{a:>10.1f}{a-b:>+10.1f}")
# Self-test CPU : calcul des metriques sur des completions de REFERENCE
# (textes fixes, pas des sorties de modele — la generation reelle est gatee GPU).
_ref = [
("Let me compute 15 + 27. 15 + 27 = 42. Wait, let me double-check: 10+20=30, 5+7=12, total 42. The answer is 42", 42.0),
("Therefore the answer is 43 because the sum seems right", 42.0),
("I am not sure how to solve this one, sorry.", 42.0),
("60 km/h for 2.5 h. Actually, distance = 60 * 2.5 = 150. The answer is 150 km", 150.0),
]
_self = reasoning_metrics_on_completions([c for c, _ in _ref], [a for _, a in _ref])
print("Self-test du harness sur 4 completions de reference (CPU, textes fixes) :")
for _k in ("n_generations", "accuracy", "format_rate", "think_length", "backtrack_rate"):
print(f" {_k:<16} = {_self[_k]}")
print(f" backtrack_markers = {BACKTRACK_MARKERS}")
print()
print("Harness pret : evaluate_reasoning_model(model, tokenizer, problems, answers, n)")
print("s'execute avant ET apres l'entrainement (branchement cellules suivantes, gate GPU).")Self-test du harness sur 4 completions de reference (CPU, textes fixes) :
n_generations = 4
accuracy = 0.5
format_rate = 0.75
think_length = 15.25
backtrack_rate = 0.5
backtrack_markers = ['wait', 'actually', 'let me reconsider', 'let me double-check', 'on second thought', 'hmm', 'correction']
Harness pret : evaluate_reasoning_model(model, tokenizer, problems, answers, n)
s'execute avant ET apres l'entrainement (branchement cellules suivantes, gate GPU).
6. Configuration LoRA + GRPO
Contrainte memoire : GRPO stocke G=4 sequences par prompt en plus du modèle. - Modèle base 4-bit : ~0.4 Go - LoRA adapters : ~15 Mo - G=4 completions (max 512 tokens) : ~1.5 Go temporaire - Total estime : ~3 Go (bien sous 8 Go RTX 3070)
from peft import LoraConfig, TaskType
LORA_CONFIG = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM,
# Qwen3.5-0.8B est un modele hybride : 18 couches en attention lineaire
# (linear_attn.out_proj) + 6 couches en attention full (q/k/v/o_proj).
# On cible les deux types d'attention + les MLP pour couvrir toute la profondeur.
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"linear_attn.out_proj",
"gate_proj", "up_proj", "down_proj",
],
)
print("LoRA config GRPO :")
print(f" rank={LORA_CONFIG.r}, alpha={LORA_CONFIG.lora_alpha}")
print(f" targets={LORA_CONFIG.target_modules}")LoRA config GRPO :
rank=8, alpha=16
targets={'linear_attn.out_proj', 'v_proj', 'gate_proj', 'o_proj', 'down_proj', 'k_proj', 'q_proj', 'up_proj'}
7. Configuration GRPO
Hyperparametres cles : - group_size = 4 : 4 completions par prompt (compromis variance/memoire) - beta = 0.04 : KL regularization (defaut Deepseek, plus faible que DPO 0.1) - clipping epsilon = 0.2 : même que PPO - learning_rate = 1e-5 : entre SFT (2e-4) et DPO (5e-7)
GRPO_CONFIG_DICT = {
"num_generations": 4, # Group size G
"beta": 0.04, # KL regularization (Deepseek default)
"per_device_train_batch_size": 1,
"gradient_accumulation_steps": 8,
"learning_rate": 1e-5,
"lr_scheduler_type": "cosine",
"warmup_steps": 1, # trl 1.9 : warmup_ratio renomme en warmup_steps
"max_completion_length": 256, # Short completions for demo
# max_prompt_length supprime en trl 1.9 (derive du modele)
"logging_steps": 2,
"save_strategy": "no",
"disable_tqdm": True, # progress bar widget non persistee dans le JSON
"max_steps": 3, # run borne (patron PT-02) : 10 prompts x G=4, 3 steps de demonstration
"output_dir": "./grpo_output",
"seed": 42,
"bf16": True,
}
print("Configuration GRPO :")
for k, v in GRPO_CONFIG_DICT.items():
print(f" {k} = {v}")Configuration GRPO :
num_generations = 4
beta = 0.04
per_device_train_batch_size = 1
gradient_accumulation_steps = 8
learning_rate = 1e-05
lr_scheduler_type = cosine
warmup_steps = 1
max_completion_length = 256
logging_steps = 2
save_strategy = no
disable_tqdm = True
max_steps = 3
output_dir = ./grpo_output
seed = 42
bf16 = True
Exercice 2 : Analyser l’impact du group size sur la mémoire
Le paramètre num_generations (group size G) contrôle le nombre de complétions générées par prompt. L’objectif est d’implémenter une fonction estimer_memoire_grpo qui calcule la mémoire supplémentaire nécessaire en fonction de G.
Objectif : écrire une fonction qui estime la VRAM consommée par GRPO selon la taille du modèle, la longueur des complétions et le group size G, puis comparer G=2, 4, 8 et 16 sur un budget de 8 Go.
Indices : - # Étape 1 : calculer la mémoire de base du modèle (params * bytes) et les activations - # Étape 2 : ajouter G * max_completion_length * hidden_size * 2 octets pour les états stockés - # Étape 3 : retourner l’estimation en Go et identifier les valeurs de G compatibles avec 8 Go
def estimer_memoire_grpo(
group_size: int,
nombre_parametres: int = 800_000_000,
octets_par_parametre: float = 0.5,
max_completion_length: int = 256,
hidden_size: int = 2048,
) -> float:
# TODO étudiant : estimer la VRAM totale en Go
# Étape 1 : mémoire du modèle quantifié et estimation des activations
memoire_modele = None # TODO : nombre_parametres * octets_par_parametre
memoire_activations = None # TODO : choisir et documenter une approximation
# Étape 2 : mémoire supplémentaire proportionnelle au group size
memoire_groupe = None # TODO : group_size * max_completion_length * hidden_size * 2
# Étape 3 : convertir le total en Go
memoire_totale_go = None # TODO : somme / 1024**3
return memoire_totale_go
for group_size in (2, 4, 8, 16):
estimation = estimer_memoire_grpo(group_size)
print(f"G={group_size:>2} : {estimation if estimation is not None else 'à calculer'} Go")
print("Exercice à compléter : estimation mémoire GRPO")G= 2 : à calculer Go
G= 4 : à calculer Go
G= 8 : à calculer Go
G=16 : à calculer Go
Exercice à compléter : estimation mémoire GRPO
8. Exemple guidé — dataset de prompts avec vérité terrain
Le GRPO génère lui-même les complétions, mais la reward de justesse introduite dans ce notebook exige une colonne answer. L’exemple suivant construit donc dix prompts de raisonnement accompagnés de leur vérité terrain numérique, puis les formate pour GRPOTrainer.
Cette cellule est un exemple résolu, distinct de l’exercice précédent consacré à l’estimation mémoire.
# Exemple résolu : dataset GRPO avec ground truth numérique (#12438)
prompts = [
"What is 15 + 27? Think step by step.",
"If a train travels 60 km/h for 2.5 hours, how far does it go?",
"How many prime numbers are there between 90 and 100? Think step by step.",
"What is the area of a circle with radius 5? Use pi = 3.14.",
"Solve for x: 3x - 7 = 14.",
"How many ways can you arrange 4 books on a shelf?",
"What is 20% of 350?",
"If it takes 5 machines 5 minutes to make 5 widgets, how long for 100 machines to make 100 widgets?",
"What is the next prime after 50?",
"Convert 72 degrees Fahrenheit to Celsius. Formula: C = (F-32) * 5/9.",
]
answers = [42, 150, 1, 78.5, 7, 24, 70, 5, 53, 22.22]
from datasets import Dataset
def format_prompts(prompts_list, answers_list):
return [
{"prompt": [{"role": "user", "content": prompt}], "answer": answer}
for prompt, answer in zip(prompts_list, answers_list)
]
dataset_grpo = Dataset.from_list(format_prompts(prompts, answers))
print(f"Dataset GRPO : {len(dataset_grpo)} prompts avec vérité terrain")
print(f"Exemple : {dataset_grpo[0]['prompt']} -> answer = {dataset_grpo[0]['answer']}")Dataset GRPO : 10 prompts avec vérité terrain
Exemple : [{'role': 'user', 'content': 'What is 15 + 27? Think step by step.'}] -> answer = 42.0
9. Construction du GRPOTrainer
trl.GRPOTrainer orchestre : 1. Generation de G completions par prompt 2. Evaluation des completions via la reward function 3. Calcul de l’avantage intra-group (normalisation) 4. Mise a jour de la policy avec clipped surrogate + KL penalty
if LOAD_MODEL_AND_TRAIN and CUDA_AVAILABLE:
from transformers import AutoModelForImageTextToText, AutoTokenizer, BitsAndBytesConfig
from trl import GRPOTrainer, GRPOConfig
from peft import get_peft_model
MODEL_NAME = "Qwen/Qwen3.5-0.8B" # Qwen3.5 : petit modele SOTA, vision-langage unifie (migration #10289)
# Quantization 4-bit
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
print("Chargement Qwen3.5-0.8B en 4-bit...")
base_model = AutoModelForImageTextToText.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
# --- Evaluation AVANT GRPO : generations du modele de base (avant LoRA/entrainement) ---
# (patron PT-02 : comparaison honnete avant/apres, pas de simulation)
base_model.eval()
baseline_prompt = "What is 15 + 27? Think step by step."
baseline_msgs = [{"role": "user", "content": baseline_prompt}]
baseline_text = tokenizer.apply_chat_template(baseline_msgs, tokenize=False, add_generation_prompt=True)
baseline_ids = tokenizer(baseline_text, return_tensors="pt").to(base_model.device)
with torch.no_grad():
baseline_out = base_model.generate(**baseline_ids, max_new_tokens=128, do_sample=False)
baseline_completion = tokenizer.decode(
baseline_out[0][baseline_ids["input_ids"].shape[1]:], skip_special_tokens=True
)
baseline_reward = combined_reward(None, [baseline_completion])[0]
print("\n=== AVANT GRPO (modele de base) ===")
print("Prompt :", baseline_prompt)
print("Completion :", baseline_completion[:200])
print("Reward combined : %.3f" % baseline_reward)
# Mesure PRE multi-metriques sur le modele de base (#12438).
METRICS_PRE = evaluate_reasoning_model(
base_model, tokenizer, prompts[:4], answers[:4], n=2, seed=1234,
)
print("Metriques PRE-GRPO (harness multi-metriques) :")
for _k in ("accuracy", "format_rate", "think_length", "backtrack_rate"):
print(f" {_k:<15} = {METRICS_PRE[_k]}")
base_model.train()
# Callback : imprime loss/reward/kl par step (la progress bar widget TRL n est pas persistee)
from transformers import TrainerCallback
class LossPrinter(TrainerCallback):
def on_log(self, args, state, control, logs=None, **kwargs):
if not logs:
return
loss_val = logs.get("loss")
reward_val = logs.get("reward")
kl_val = logs.get("kl")
parts = ["step %d" % state.global_step]
if loss_val is not None:
parts.append("loss = %.4f" % loss_val)
if reward_val is not None:
parts.append("reward = %.4f" % reward_val)
if kl_val is not None:
parts.append("kl = %.4f" % kl_val)
print(" | ".join(parts))
print("Configuration GRPOConfig...")
grpo_config = GRPOConfig(**GRPO_CONFIG_DICT)
print("Construction GRPOTrainer...")
# trl 1.9.2 + peft 0.13 : passer peft_config AU TRAINER (le wrap PEFT se fait apres
# l'init). get_peft_model() avant le trainer declenche le bug `target_parameters`
# retire de peft 0.13 (patron PT-11b, verifie firsthand).
trainer = GRPOTrainer(
model=base_model,
args=grpo_config,
processing_class=tokenizer,
train_dataset=dataset_grpo,
reward_funcs=[combined_reward], # Pass reward function directly
peft_config=LORA_CONFIG,
callbacks=[LossPrinter()],
)
model = trainer.model # modele PEFT wrappe par le trainer (pour print + eval apres)
model.print_trainable_parameters()
print(f"\nGRPOTrainer pret. {len(dataset_grpo)} prompts, G={GRPO_CONFIG_DICT['num_generations']}.")
print("Lancement de l'entrainement...")
train_result = trainer.train()
print(f"\nEntrainement GRPO termine.")
print(f"Loss finale : {train_result.training_loss:.4f}")
else:
print("Skip : entrainement GRPO non execute (LOAD_MODEL_AND_TRAIN=False ou pas de CUDA)")
print()
print("Pour executer le vrai GRPO :")
print(" 1. LOAD_MODEL_AND_TRAIN = True (migration Qwen3.5, mandat #10289)")
print(" 2. GPU avec >= 6 Go VRAM disponible")
print(" 3. Le notebook execute alors le vrai GRPO (modele de base -> 3 steps),")
print(" et l'evaluation qualitative avant/apres (cellule 22) est reelle, pas simulee.")Skip : entrainement GRPO non execute (LOAD_MODEL_AND_TRAIN=False ou pas de CUDA)
Pour executer le vrai GRPO :
1. LOAD_MODEL_AND_TRAIN = True (migration Qwen3.5, mandat #10289)
2. GPU avec >= 6 Go VRAM disponible
3. Le notebook execute alors le vrai GRPO (modele de base -> 3 steps),
et l'evaluation qualitative avant/apres (cellule 22) est reelle, pas simulee.
10. Evaluation qualitative — outputs avant/après
La metrique cle du GRPO est behaviorale : est-ce que les completions deviennent meilleures qualitativement ?
Méthode : 1. Generer 3 completions pour le même prompt avant/après GRPO 2. Comparer la structure, la presence de raisonnement, et le reward moyen 3. Documenter les changements observables
Pas de claim BEATS quantitative (terrain pedagogique, 10 prompts = sample insuffisant pour stats). Verdict honnete : “amelioration qualitative observee” ou “INCONCLUSIF”.
# Evaluation qualitative reelle : avant (cellule 20) vs apres GRPO (cette cellule)
if LOAD_MODEL_AND_TRAIN and CUDA_AVAILABLE:
model.eval()
eval_prompt = "What is 15 + 27? Think step by step."
eval_msgs = [{"role": "user", "content": eval_prompt}]
eval_text = tokenizer.apply_chat_template(eval_msgs, tokenize=False, add_generation_prompt=True)
eval_ids = tokenizer(eval_text, return_tensors="pt").to(model.device)
with torch.no_grad():
eval_out = model.generate(**eval_ids, max_new_tokens=128, do_sample=False)
eval_completion = tokenizer.decode(
eval_out[0][eval_ids["input_ids"].shape[1]:], skip_special_tokens=True
)
eval_reward = combined_reward(None, [eval_completion])[0]
print("=== APRES GRPO (modele entraine, 3 steps) ===")
print("Prompt :", eval_prompt)
print("Completion :", eval_completion[:200])
print("Reward combined : %.3f" % eval_reward)
print()
# Verdict honnete : 3 steps = trop court pour converger (patron PT-02 / PT-03)
if eval_reward >= baseline_reward:
print("Verdict : la reward est stable ou legerement amelioree sur 3 steps.")
else:
print("Verdict : reward en baisse sur 3 steps (run trop court, LR 1e-5,")
print(" modele deja aligne) — c'est le resultat mesure, pas un bug.")
print("Note : 10 prompts x 3 steps = echantillon trop petit pour un claim BEATS")
print(" (multi-seed >= 4 + 2 sigma requis). Verdict : INCONCLUSIF")
print(" sur metriques, observation qualitative documentee.")
# Harness multi-metriques (#12438) : avant / apres sur le meme echantillon.
METRICS_POST = evaluate_reasoning_model(
model, tokenizer, prompts[:4], answers[:4], n=2, seed=1234,
)
print()
print("=== Harness multi-metriques : avant / apres GRPO (#12438) ===")
if "METRICS_PRE" in globals() and METRICS_PRE is not None:
print_before_after(METRICS_PRE, METRICS_POST)
else:
for _k in ("accuracy", "format_rate", "think_length", "backtrack_rate"):
print(f" {_k:<15} = {METRICS_POST[_k]}")
else:
print("Skip : evaluation reelle non executee (LOAD_MODEL_AND_TRAIN=False ou pas de CUDA)")Skip : evaluation reelle non executee (LOAD_MODEL_AND_TRAIN=False ou pas de CUDA)
11. 5 pieges du GRPO en pratique
Piege 1 : Reward hacking
Le modèle peut trouver des shortcuts pour maximiser la reward sans ameliorer reellement la qualite. Ex : repeter “therefore” 50 fois pour maximiser keyword_reward. Solution : plafonder les rewards, combiner plusieurs signaux, monitoring qualitatif.
Piege 2 : KL collapse (beta trop faible)
Si \(\beta\) est trop petit, la policy diverge de la reference et genere du texte incoherent. Solution : \(\beta = 0.04\) (defaut Deepseek), monitorer KL divergence pendant training.
Piege 3 : Group size mal calibre
- G trop petit (2) : variance elevee, signal bruite
- G trop grand (16+) : OOM sur GPU 8 Go Solution : G=4 sur RTX 3070, G=8 sur GPU 24 Go. Profiler VRAM avant.
Piege 4 : Mode collapse
Toutes les completions deviennent identiques (le modèle exploite un unique pattern recompense). Solution : temperature > 0 pendant generation, diversifier les reward functions.
Piege 5 : Reward sparsity
Si la reward est binaire (0 ou 1), l’avantage intra-group est extreme (+1 ou -1) et le gradient est instable. Solution : utiliser des rewards continus (score de similarite, log-probabilite,BLEU partiel).
Exercice 3 : Implementation de la normalisation intra-group
Le coeur du GRPO est le calcul de l’avantage par normalisation intra-group. L’objectif est d’implementer manuellement cette étape : pour un groupe de G completions avec leurs rewards, calculer les avantages normalises.
Objectif : ecrire une fonction calculer_avantages qui prend une liste de rewards et retourne les avantages normalises selon la formule GRPO.
Indices : - # Étape 1 : Calculer la moyenne et l’ecart-type du groupe - # Étape 2 : Appliquer la normalisation : (reward - moyenne) / (ecart_type + epsilon) - # Indice : ajouter un petit epsilon (1e-8) pour eviter la division par zero quand tous les rewards sont egaux
import numpy as np
def calculer_avantages(rewards: list[float], epsilon: float = 1e-8) -> list[float]:
# TODO etudiant : implementer la normalisation intra-group GRPO
rewards_array = np.array(rewards)
# Etape 1 : calculer moyenne et ecart-type
mu = None # TODO etudiant : np.mean(rewards_array)
sigma = None # TODO etudiant : np.std(rewards_array)
# Etape 2 : normaliser
advantages = None # TODO etudiant : (rewards_array - mu) / (sigma + epsilon)
return advantages # TODO etudiant : retourner la liste des avantages
print("Exercice a completer : normalisation intra-group")Exercice a completer : normalisation intra-group
Bilan — GRPO : la recette Deepseek-R1 (RL pur, sans données humaines)
Le GRPO (Group Relative Policy Optimization) est la méthode qui a fait emergent Deepseek-R1 en janvier 2025 : un modèle de raisonnement rivalisant OpenAI o1 sans données de raisonnement humain. La hiérarchie du post-training devient clair ici : SFT (PT-02) apprend le format, DPO (PT-03) apprend les préférences humaines, GRPO apprend a raisonner via RL pur sur rewards heuristiques.
L’innovation centrale : l’avantage relatif intra-group
Pour chaque prompt \(x\), GRPO echantillonne G completions depuis la policy. Plutot que d’estimer une value function (coûteuse, instable comme en PPO), GRPO calcule l’avantage par normalisation intra-group :
\[\hat{A}_i = \frac{R_i - \mu_G}{\sigma_G}\]
ou \(\mu_G\) et \(\sigma_G\) sont la moyenne et l’ecart-type des rewards du groupe. Pas de critic, pas de value network : le groupe lui-même sert de baseline. C’est cette simplification qui rend le GRPO tractable sur un seul GPU pedagogique.
Pourquoi GRPO fait emerger du raisonnement (et pas DPO)
- DPO consomme des paires de préférences humaines statiques — il ne peut pas depasser ce que les humains ont montre.
- GRPO genere ses propres completions et les selectionne par le reward : si raisonner en étapes augmente le reward moyen, le gradient pousse vers ce comportement — même si personne ne lui a jamais montre de chain-of-thought.
C’est l’origine de l’emergence : le raisonnement n’est pas enseigne, il est selectionne par la structure de la recompense.
Les 5 pieges du GRPO en pratique
- Reward hacking : le modèle trouve des shortcuts (repeter “therefore”). Solution : plafonner les rewards, combiner plusieurs signaux, monitoring qualitatif.
- KL collapse (\(\beta\) trop faible) : la policy diverge de la reference.
- Reward sparse : si aucune completion du groupe ne reussit, avantage = 0 partout.
- Groupe trop petit : \(G < 4\) rend la normalisation intra-group trop bruitee.
- Reward non-differentiable mal wrappee : la reward function doit eter uni-variable scalaire, sinon le gradient casse.
Honnetete de l’evaluation (rappel CoursIA)
Ce notebook produit un proof-of-concept : single seed, evaluation behaviorale qualitative (avant/après GRPO), pas de claim BEATS quantitatif. Toute claim quantitative exige >= 4 seeds + edge >= 2 sigma cross-seed (PT-06).
Position dans le track GenAI/PostTraining
PT-01 (intro) > PT-02 (SFT) > PT-03 (DPO) > PT-04 (GRPO, RL heuristique) > PT-05 (RLVR, RL verifiable). PT-04 installe le mécanisme GRPO (intra-group advantage) ; PT-05 va plus loin en remplacant la reward heuristique gamable (longueur, mots-cles) par un verifieur exact SymPy — eliminant par construction le piege n°1 (reward hacking).
12. Transition vers PT-05 — RLVR (Reinforcement Learning with Verifiable Rewards)
Le GRPO avec reward function “soft” (longueur, mots-cles) est un bon point de depart. Mais le vrai potentiel du GRPO emerge avec des rewards verifiables exacts :
| Aspect | GRPO (ce notebook) | RLVR (PT-05) |
|---|---|---|
| Reward | Fonction heuristique (longueur, mots-cles) | Verifier exact (sympy, exec sandbox) |
| Domaine | General | Mathematique / Code |
| Ambiguite | Possible (reward subjective) | Nulle (correct/incorrect exact) |
| Modèle cible | Qwen3.5-0.8B | Qwen3.5-Math (migration RLVR en cours) |
| Pattern Deepseek-R1 | Étape 1 (alignment general) | Étape 2 (reasoning mathematique) |
Le RLVR = la variante GRPO qui a fait emerger le reasoning spontane dans Deepseek-R1. Quand la recompense est exacte (pas de bruit), le RL explore librement et decouvre des chaînes de raisonnement non prevues.
Prochaines étapes : - PT-05 : GRPO + verifier sympy sur GSM8K (math word problems) - PT-06 : Evaluation comparative SFT/DPO/GRPO/RLVR