PT-04 — Group Relative Policy Optimization (GRPO)

# Parameters
BATCH_MODE = "true"

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

  1. Pas besoin de baseline apprise : la moyenne du groupe sert de baseline naturelle
  2. Variance reduite : normaliser par \(\sigma_G\) reduit le bruit
  3. Gradients informatifs : même avec des rewards sparse, le classement relatif fournit un signal
  4. 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

  1. Reward hacking : le modèle trouve des shortcuts (repeter “therefore”). Solution : plafonner les rewards, combiner plusieurs signaux, monitoring qualitatif.
  2. KL collapse (\(\beta\) trop faible) : la policy diverge de la reference.
  3. Reward sparse : si aucune completion du groupe ne reussit, avantage = 0 partout.
  4. Groupe trop petit : \(G < 4\) rend la normalisation intra-group trop bruitee.
  5. 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

Retour au sommet