RL Post-Training — 4 : DPO offline vs RL online — même tâche, même budget
Deux familles dominent le post-training des LLM : le RL online (PPO, GRPO — la politique génère, un reward note, la politique est corrigée) et l’alignement offline par préférences (DPO — Rafailov et al. 2023 : pas de reward model explicite ni de génération pendant l’entraînement, on optimise directement la marge de log-probabilités entre une réponse choisie et une rejetée). La littérature s’accorde sur le compromis : DPO est simple et économe, le RL online est plus fort quand le reward est fiable — mais sur la même tâche, au même budget, sur le même modèle, que mesure-t-on à 0.8B sur 8 Go ? C’est la comparaison qui manquait au pipeline de la série, et ce notebook la fait avec le protocole exigé pour tout claim de supériorité : multi-seed ≥ 4 et test de Diebold-Mariano sur la perte de précision.
Où ce grain se situe dans la série (#11297) : le grain 2 (rlpt_2_grpo_minimal) a établi la recette GRPO, le grain 3 la discipline de mesure (évaluer en mode eval() avant toute lecture, seeder le sampling — les helpers ci-dessous l’appliquent), et ce grain 4 n’apporte ni recette ni instrument : il les confronte. La question directrice est volontairement étroite : à modèle, tâche et budget fixés, le mécanisme d’apprentissage — gradient sur des paires figées contre avantage sur des rollouts frais — produit-il une différence mesurable ? Tout le protocole qui suit n’existe que pour rendre cette question décidable.
Le protocole
Tâche : soustractions conversationnelles a - b (zone mesurée au grain 2 : le modèle vaut ~0.7 en greedy, ~0.5 en sampling — assez de marge pour mesurer un progrès, assez de réussite pour que l’apprentissage ait du signal).
Bras DPO (offline) : le modèle s’évalue lui-même — 4 échantillons par prompt en mode sampling, la réponse correcte devient chosen, une incorrecte devient rejected. Aucune donnée externe, aucun reward model : la vérité terrain de la tâche fabrique les préférences.
Bras GRPO (online) : le reward exact de la série (dernier nombre == a - b), la recette du grain 2. C’est le représentant online de référence à ce budget : le PPO classique (critic + reward model) vit au grain 1 sur un toy CPU — sur 0.8B/8 Go, le online sans critic est GRPO.
Budget égal : 40 steps d’optimisation chacun, même config QLoRA (r=8), même graine maîtresse.
Verdict : multi-seed {42, 0, 1, 7} puis test de Diebold-Mariano (HAC) sur les erreurs par prompt — BEATS / BEATEN BY / INCONCLUSIVE, jamais « prometteur ».
C’est le grain 4/4 de la série (#11297). Stack du grain 2 : Qwen3.5-0.8B local en QLoRA 4-bit, trl 1.9.2, seed fixée — reproductible de bout en bout.
# Environnement : versions et GPU (env conda coursia-ml-training, torch cu124)import os, random, re, time, sysos.environ.setdefault("TOKENIZERS_PARALLELISM", "false")import torchimport transformers, trl, peft, datasetsimport numpy as npprint("transformers", transformers.__version__, "| trl", trl.__version__,"| peft", peft.__version__, "| torch", torch.__version__)print("GPU :", torch.cuda.get_device_name(0), "| VRAM totale",f"{torch.cuda.get_device_properties(0).total_memory /2**30:.1f} Go")assert torch.cuda.is_available(), "Ce notebook exige un GPU (QLoRA 4-bit)."
# Tache : soustractions a - b avec b >= 10 (zone GRPO mesuree au grain 2), format conversationnelN_PROMPTS =48random.seed(42) # graines fixees : paires, entraitements et verdicts reproductiblespairs = [(random.randint(30, 99), random.randint(10, 29)) for _ inrange(N_PROMPTS)]def mk_prompt(a, b):return [{"role": "user", "content": f"What is {a} - {b}? Answer with just the number."}]def parse_number(completion): ms = re.findall(r"\d+", completion.replace(",", ""))return ms[-1] if ms elseNonedef reward_exact(prompts, completions, **kw): gt = kw.get("ground_truth")return [1.0if parse_number(c ifisinstance(c, str) else c[0]["content"]) == g else0.0for c, g inzip(completions, gt)]import datasets as hfdsds = hfds.Dataset.from_dict({"prompt": [mk_prompt(a, b) for a, b in pairs],"ground_truth": [str(a - b) for a, b in pairs],})print(ds)print("exemple :", ds[0]["prompt"][0]["content"])
Dataset({
features: ['prompt', 'ground_truth'],
num_rows: 48
})
exemple : What is 44 - 10? Answer with just the number.
# Helpers de generation/evaluation — eval() AVANT toute mesure (lecon rlpt_3)from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfigMODEL_PATH = os.path.expanduser("~/models/qwen35-0.8b")tok = AutoTokenizer.from_pretrained(MODEL_PATH)def gen_texts(m, prompt, temp, max_new=16, n=1): enc = tok.apply_chat_template(prompt, add_generation_prompt=True, return_tensors="pt") ids = enc["input_ids"].to("cuda") out = m.generate(ids, max_new_tokens=max_new, do_sample=temp >0, temperature=temp if temp >0elseNone, pad_token_id=tok.eos_token_id, num_return_sequences=n)return [tok.decode(o[ids.shape[1]:], skip_special_tokens=True) for o in out]def eval_policy(m, temp, n=48, return_errors=False):"""Exactitude sur n prompts — en mode eval, toujours. Erreurs binaires pour le DM test.""" was_training = m.training m.eval() errs = []for i inrange(n): a, b = pairs[i] num = parse_number(gen_texts(m, mk_prompt(a, b), temp)[0]) errs.append(0.0if num ==str(a - b) else1.0)if was_training: m.train() acc =1-sum(errs) / nreturn (acc, np.array(errs)) if return_errors else accdef load_base(): bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True) m = AutoModelForCausalLM.from_pretrained(MODEL_PATH, quantization_config=bnb, device_map="cuda") m.config.use_cache =Falsereturn mmodel = load_base()print("modele charge, VRAM", f"{torch.cuda.memory_allocated()/2**30:.2f} Go")
modele charge, VRAM 0.72 Go
# Baseline : la politique de depart, greedy et sampling (moyenne des 2 lectures)base_g = eval_policy(model, 0.0) # greedy : deterministe, pas de seed requistorch.manual_seed(42) # sampling : seed CUDA, sinon chaque exec tire differemmentbase_s = eval_policy(model, 1.0)print(f"BASELINE greedy : exactitude {base_g:.3f}")print(f"BASELINE sampling : exactitude {base_s:.3f}")
La politique de départ vaut 0.688 en greedy et 0.438 en sampling (temp 1.0, seedée) — la zone mesurée au grain 2 : assez de réussite pour que les deux familles d’apprentissage aient du signal, assez d’échec pour mesurer un progrès. L’écart greedy−sampling (0.25) dit aussi que la distribution est piquée mais pas déterministe : les deux lectures mesureront des choses différentes d’un même état de politique.
Ces deux nombres servent de référence pour tous les gains qui suivent : +0.124 en greedy sera le gain commun aux deux bras. Deux repères de lecture avant de continuer : la granularité de la mesure est 1/48 ≈ 0.021 (chaque réponse bascule l’exactitude d’un prompt — tout écart plus fin qu’un prompt n’est pas mesurable), et le modèle chargé en 4-bit n’occupe que 0.72 Go : le budget de 8 Go laisse la place aux deux familles d’entraînement, ce n’est pas la mémoire qui décidera.
1. Bras offline : DPO sur préférences auto-fabriquées
DPO (Direct Preference Optimization) réécrit l’objectif RLHF sans boucle RL : au lieu d’entraîner un reward model puis une politique contre lui, il optimise directement
la politique rapproche chosen et éloigne rejected, relativement à une référence figée (β = 0.1 règle la force du lien). Pas de génération pendant l’entraînement : deux forward (chosen, rejected) sur la politique + deux sur la référence — un entraînement supervisé déguisé.
La question des données : d’où viennent les préférences ? Ici, du modèle lui-même — le protocole le plus honnête pour cette comparaison, car il n’apporte aucune connaissance externe que GRPO n’aurait pas : 4 échantillons par prompt en sampling, chosen = une réponse correcte (vérifiée contre a - b), rejected = une incorrecte. Le dataset ne retient que les prompts où les deux espèces coexistent — c’est la condition pour qu’une paire porte de l’information.
# Rollouts : le modele s'etiquete lui-meme (4 samples/prompt, temp 1.0)model.eval()torch.manual_seed(42) # rollouts reproducibles : le dataset DPO est une fonction du seedpref_rows = []t0 = time.time()for i, (a, b) inenumerate(pairs): samples = gen_texts(model, mk_prompt(a, b), 1.0, n=4) nums = [parse_number(s) for s in samples] good =next((s for s, n inzip(samples, nums) if n ==str(a - b)), None) bad =next((s for s, n inzip(samples, nums) if n isnotNoneand n !=str(a - b)), None)if good isnotNoneand bad isnotNone: pref_rows.append({"prompt": mk_prompt(a, b),"chosen": [{"role": "assistant", "content": good.strip()}],"rejected": [{"role": "assistant", "content": bad.strip()}], })ds_pref = hfds.Dataset.from_list(pref_rows)print(f"rollouts {time.time()-t0:.0f}s -> {len(ds_pref)} paires exploitables / {N_PROMPTS} prompts")print("exemple :", ds_pref[0]["prompt"][0]["content"])print(" chosen :", repr(ds_pref[0]["chosen"][0]["content"]))print(" rejected:", repr(ds_pref[0]["rejected"][0]["content"]))
rollouts 64s -> 31 paires exploitables / 48 prompts
exemple : What is 44 - 10? Answer with just the number.
chosen : '34'
rejected: '24'
Lecture du résultat
31 paires exploitables sur 48 prompts (65 %) : pour les 17 autres, les 4 échantillons étaient tous corrects (pas de rejected) ou tous incorrects (pas de chosen) — la paire n’aurait porté aucune information. C’est une propriété de la baseline sampling (0.438) : le modèle est dans la zone où l’auto-étiquetage couvre les deux tiers des prompts. On note aussi la nature du signal : chosen et rejected sur le même prompt ne diffèrent que par la justesse du nombre — DPO n’apprendra pas un style, seulement à préférer les réponses correctes de SA PROPRE distribution.
Conséquence structurelle à garder pour la suite : DPO ne recevra de gradient que sur ces 31 prompts, quand GRPO en recevra sur les 48 — un écart de couverture inhérent à l’auto-fabrication, que l’exercice 3 demande précisément de sonder. Et le coût de fabrication (64 s de rollouts) est ici quasi gratuit parce que la vérité terrain de la tâche est exécutable ; la section 5 montrera ce qu’il devient sur une tâche réelle.
# Run DPO : 40 steps, beta=0.1, lr=5e-6 (zone sure sous le seuil d'effondrement 5e-5 du hub)from trl import DPOConfig, DPOTrainerfrom peft import LoraConfiglora = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM")cfgD = DPOConfig(output_dir="rlpt4_dpo", per_device_train_batch_size=4, max_steps=40, learning_rate=5e-6, beta=0.1, logging_steps=5, report_to=[], seed=42, save_strategy="no", max_length=96)trainerD = DPOTrainer(model=model, args=cfgD, train_dataset=ds_pref, processing_class=tok, peft_config=lora)torch.cuda.reset_peak_memory_stats()t0 = time.time()trainerD.train()print(f"TRAIN_DONE_DPO {(time.time()-t0)/60:.1f} min ({(time.time()-t0)/40:.1f} s/step) | "f"VRAM pic {torch.cuda.max_memory_allocated()/2**30:.2f} Go")histD = [h for h in trainerD.state.log_history if"rewards/margins"in h]for h in histD:print(f"step {h['step']:3d} : margin {h['rewards/margins']:.3f} | "f"acc {h.get('rewards/accuracies', float('nan')):.2f} | loss {h.get('loss', float('nan')):.3f}")dpo_g, dpo_errs = eval_policy(trainerD.model, 0.0, return_errors=True)torch.manual_seed(42)dpo_s = eval_policy(trainerD.model, 1.0)print(f"POST-DPO greedy : exactitude {dpo_g:.3f} (baseline {base_g:.3f})")print(f"POST-DPO sampling : exactitude {dpo_s:.3f} (baseline {base_s:.3f})")
La marge DPO reste minuscule (−0.000 → 0.016) : la politique bouge à peine en log-probabilité — et pourtant rewards/accuracies monte de 0.35 à 0.90 (elle classe correctement chosen vs rejected) et la politique mesurée gagne +0.124 en greedy (0.688 → 0.812) et +0.083 en sampling (0.438 → 0.521). C’est la leçon d’échelle de DPO : β=0.1 bride le déplacement absolu, mais un petit déplacement bien orienté suffit à changer des décisions argmax. À retenir pour lire la suite : DPO a optimisé un objectif (marge de préférence) qui n’est PAS l’exactitude — et l’exactitude a monté quand même.
Deux précisions chiffrées pour le bilan : la loss part de 0.693 — la valeur de ln 2, celle d’un classifieur qui choisit au hasard entre chosen et rejected — et ne descend qu’à 0.685, confirmation que l’objectif est à peine optimisé même quand l’exactitude monte. Côté ressources : 3.6 s/step et un pic mémoire à 2.13 Go (les quatre forward : chosen et rejected, sur la politique ET sur la référence), à mettre en regard du bras GRPO qui suit.
2. Bras online : GRPO, même budget
Pourquoi GRPO est le comparateur honnête, au-delà de la contrainte mémoire (pas de critic à 8 Go, cf. introduction) : c’est l’asymétrie de supervision qui rend la confrontation informative. GRPO consomme le reward exact de la tâche pendant l’entraînement — un scalaire par génération, émis en continu. DPO consomme des préférences fabriquées avant l’entraînement, plus pauvres (une paire par prompt, disponible sur 65 % d’entre eux) mais capitalisables : les mêmes paires resservent sans re-générer. Chaque bras est ainsi dans sa condition nominale — si l’offline tient la comparaison malgré une supervision moins riche, c’est un fait sur cette échelle de budget, pas un verdict général sur les deux familles.
La recette du grain 2, à l’identique : group rollouts (4 générations par prompt), avantage centré par groupe (pas de critic), reward exact, β=0 (DAPO, pas de pénalité KL — le DPO, lui, a son ancre de référence intégrée). 40 steps, comme le bras DPO. La comparaison des deux lectures (greedy : capacité déterministe ; sampling : concentration de la distribution) dit lequel des deux mécanismes chacun actionne.
La courbe reward raconte l’apprentissage online : 0.450 → 0.700 avec la bosse classique au milieu (0.250 au step 20 — la variance de groupe fait des pauses), 4.1 s/step. Et le résultat mesuré est frappant de similarité avec le bras DPO : greedy 0.688 → 0.812 (le même gain de +0.124, la même valeur finale au prompt près), sampling 0.438 → 0.500. Deux mécanismes d’apprentissage différents — gradient sur paires figées vs avantage sur rollouts frais — aboutissent au même état mesuré. Coïncidence sur un seed ? C’est exactement ce que le multi-seed tranche.
Côté ressources, le contraste avec le bras DPO est franc : 4.1 s/step et un pic mémoire de 1.47 Go, contre 3.6 s/step et 2.13 Go — le online économise environ un tiers de mémoire (pas de forward sur une référence figée) mais paie la génération à chaque step ; les deux tiennent largement dans les 8 Go, le modèle de base n’en occupant que 0.72.
Les deux lectures d’une politique, et pourquoi on paie les deux
Chaque politique est mesurée deux fois, et les deux nombres ne disent pas la même chose. La lecture greedy (température 0, déterministe) échantillonne le mode de la distribution : elle répond à « quelle réponse le modèle considère-t-il comme LA meilleure » — c’est la lecture qu’un produit utilise en production par défaut, et celle qui a le plus de valeur directe. La lecture sampling (température 1.0, seedée) échantillonne la masse : elle répond à « quelle proportion de la distribution est correcte » — c’est la lecture qui mesure la santé de l’exploration, et celle sur laquelle l’entraînement online exerce son gradient (GRPO génère à température 1.0). Une méthode peut améliorer l’une sans l’autre : concentrer la distribution autour de réponses déjà majoritairement correctes monte le sampling sans toucher le mode ; rendre le mode correct sans rétrécir la distribution monte le greedy en laissant le sampling erratique. C’est pourquoi le tableau final reporte les deux colonnes, et pourquoi un verdict « meilleur » qui ne citerait qu’une des deux lectures serait incomplet par construction.
3. Multi-seed : {42, 0, 1, 7}
Un seul run ne prouve rien (variance d’initialisation LoRA et d’ordre des batchs). Le protocole rejoue les deux bras sur 4 graines — la paire de préférences reste celle du seed maître 42 (la variance mesurée est celle de l’entraînement, pas du rollout : à budget égal de GPU, c’est le choix honnête, et il est déclaré). Chaque seed : DPO 40 steps puis GRPO 40 steps sur base rechargée.
Pourquoi le seed est le facteur de variance à cette échelle : à lr 5e-6 sur 40 steps, l’entraînement n’est qu’une perturbation courte de la politique de départ — l’initialisation LoRA (tirée au seed) et l’ordre des batchs choisissent la direction d’une descente trop brève pour s’auto-corriger. La section 5 chiffrera l’ordre de grandeur (dispersion inter-seed environ dix fois l’écart entre méthodes) ; retenez ici le mécanisme : plus le budget est court, plus la trajectoire dépend de son point de départ, et plus la variance du run unique domine l’effet qu’on prétend mesurer. C’est le régime où un run unique raconte n’importe quoi dans un sens comme dans l’autre, et où seules la moyenne multi-seed et le test apparié séparent le finding du bruit.
# Multi-seed : les deux bras sur seeds 0/1/7 (le 42 est au-dessus), base rechargee par rundel trainerG, modeltorch.cuda.empty_cache()rows = []for seed in (42, 0, 1, 7):for arm in ("DPO", "GRPO"): m = load_base()if arm =="DPO": cfg = DPOConfig(output_dir=f"rlpt4_ms_{arm}{seed}", per_device_train_batch_size=4, max_steps=40, learning_rate=5e-6, beta=0.1, logging_steps=40, report_to=[], seed=seed, save_strategy="no", max_length=96) tr = DPOTrainer(model=m, args=cfg, train_dataset=ds_pref, processing_class=tok, peft_config=lora)else: cfg = GRPOConfig(output_dir=f"rlpt4_ms_{arm}{seed}", per_device_train_batch_size=4, num_generations=4, max_completion_length=48, max_steps=40, learning_rate=5e-6, beta=0.0, logging_steps=40, report_to=[], seed=seed, save_strategy="no") tr = GRPOTrainer(model=m, args=cfg, train_dataset=ds, processing_class=tok, reward_funcs=[reward_exact], peft_config=lora) t0 = time.time() tr.train() g, errs = eval_policy(tr.model, 0.0, return_errors=True) torch.manual_seed(seed) # eval sampling re-seedee : run reproducible s = eval_policy(tr.model, 1.0) rows.append({"seed": seed, "arm": arm, "greedy": g, "sampling": s,"errs": errs, "min": (time.time() - t0) /60})print(f"seed {seed}{arm:4s} : greedy {g:.3f} | sampling {s:.3f} "f"| {rows[-1]['min']:.1f} min")del tr, m torch.cuda.empty_cache()import json as _json_json.dump([{k: (v.tolist() ifisinstance(v, np.ndarray) else v) for k, v in r.items()} for r in rows],open("rlpt4_multi_seed.json", "w"))print("\n--- tableau multi-seed ---")print(f"{'seed':>4} | {'DPO g/s':>13} | {'GRPO g/s':>13}")for seed in (42, 0, 1, 7): d =next(r for r in rows if r["seed"] == seed and r["arm"] =="DPO") g =next(r for r in rows if r["seed"] == seed and r["arm"] =="GRPO")print(f"{seed:>4} | {d['greedy']:.3f}/{d['sampling']:.3f} | {g['greedy']:.3f}/{g['sampling']:.3f}")dm = {a: np.mean([r["greedy"] for r in rows if r["arm"] == a]) for a in ("DPO", "GRPO")}sm = {a: np.mean([r["sampling"] for r in rows if r["arm"] == a]) for a in ("DPO", "GRPO")}print(f"moyennes greedy : DPO {dm['DPO']:.3f} | GRPO {dm['GRPO']:.3f}")print(f"moyennes sampling: DPO {sm['DPO']:.3f} | GRPO {sm['GRPO']:.3f}")
Sur 4 seeds, DPO atteint greedy 0.812 sur les quatre (0.812/0.812/0.812/0.812 — la valeur est quantifiée par les 48 prompts : 39/48) ; GRPO l’atteint sur deux et s’arrête à 0.792 (38/48) sur les deux autres. Moyennes : DPO 0.812 vs GRPO 0.802 en greedy, 0.484 vs 0.469 en sampling — DPO est devant à chaque seed, mais l’écart (0.010 = un demi-prompt) est de l’ordre de la granularité de la mesure. Le sampling, lui, est bruyant pour les deux bras (0.396 → 0.562) : c’est la lecture la plus variable de la politique. Un avantage systématique mais minuscule : pile le cas où l’œil conclut « DPO gagne » et où seul un test statistique peut dire si c’est réel.
Le tableau cache aussi le prix réel du protocole : chaque ligne = deux entraînements complets sur base rechargée (3.8 à 4.1 min pour DPO, 4.3 à 4.7 min pour GRPO), soit environ 34 min de GPU pour la section — le online coûte 0.4 à 0.6 min de plus à chaque seed, à budget de steps identique. La rigueur statistique n’est pas gratuite : c’est le prix à comparer au coût d’une conclusion fausse tirée d’un run unique.
Pourquoi un seul test sur 192 paires, et pas quatre tests par seed
On aurait pu lancer le DM quatre fois (un par seed) et compter les verdicts. Trois raisons de ne pas le faire. D’abord la puissance : un test par seed compare 48 paires ; le test poolé en compare 192 — les petits tests séparés auraient surtout produit des INCONCLUSIVE par manque de données. Ensuite le problème des tests multiples : quatre tests à α=0.05 laissent ~18 % de chance de trouver au moins un « significatif » sous le hasard pur ; pooler contrôle ce risque par construction. Enfin l’appariement : le DM exige que les erreurs comparées portent sur les mêmes observations — ici, les mêmes 48 prompts au même mode greedy ; le pooling inter-seeds conserve l’appariement prompt-à-prompt à l’intérieur de chaque bloc de 48. La contrepartie, assumée : les quatre seeds ne sont pas indépendants au sens strict (même dataset de préférences, même base), le test est donc plutôt conservateur — il penche vers INCONCLUSIVE, ce qui est le biais de précaution qu’on veut quand la question est « peut-on affirmer qu’une méthode bat l’autre ».
4. Le verdict : test de Diebold-Mariano
La règle de la série (et du dépôt) pour tout claim de supériorité : pas de « meilleur en moyenne » à l’œil — le test de Diebold-Mariano (variance HAC de Newey-West, correction HLN petit échantillon) sur la perte de précision (loss MSE sur les erreurs binaires par prompt : 0 si la réponse est correcte, 1 sinon). Sur les 4 seeds, chaque seed vote avec ses 48 erreurs appariées (mêmes prompts, même mode greedy) : 192 paires d’erreurs, un seul test. mean_loss_diff < 0 et p < 0.05 = BEATS ; l’inverse = BEATEN ; sinon INCONCLUSIVE — et c’est un verdict, pas un échec de mesure.
Ce que le test mesure exactement : DM ne compare pas deux moyennes indépendantes, il compare les différences d’erreur prompt par prompt — pour chaque prompt i, la différence appariée e_DPO(i) − e_GRPO(i), puis la moyenne de ces différences contre zéro. C’est pourquoi chaque seed contribue ses 48 erreurs dans l’ordre des prompts et pas une moyenne agrégée : l’appariement élimine la difficulté propre à chaque prompt (un prompt difficile pèse pareil dans les deux bras). Le seuil p < 0.05 est la convention du dépôt : accepter au plus 5 % de risque de déclarer un vainqueur qui n’existe pas ; tout écart mesuré mais non significatif est rapporté INCONCLUSIVE, jamais comme « tendance ».
# DM test : DPO (modele) vs GRPO (baseline), erreurs binaires par prompt, 4 seeds poolesfrom pathlib import Path_dm_dir =next((p /"QuantConnect/ML-Training-Pipeline/scripts"for p in [Path.cwd(), *Path.cwd().parents]if (p /"QuantConnect/ML-Training-Pipeline/scripts/dm_test.py").exists()), None)assert _dm_dir isnotNone, "dm_test.py introuvable (lancer depuis l'arbre du repo)"sys.path.insert(0, str(_dm_dir))from dm_test import dm_verdicterrs_dpo = np.concatenate([r["errs"] for r in rows if r["arm"] =="DPO"])errs_grpo = np.concatenate([r["errs"] for r in rows if r["arm"] =="GRPO"])v = dm_verdict(errs_dpo, errs_grpo, loss_fn="mse")print(f"DM (mse, HLN) : stat {v['dm_statistic']:.3f} | p {v['p_value']:.4f}")print(f"mean_loss_diff : {v['mean_loss_diff']:+.4f} (negatif = DPO perd moins)")print(f"VERDICT : {v['verdict']}")print(f"biais erreurs : DPO {errs_dpo.mean():.4f} | GRPO {errs_grpo.mean():.4f} "f"(diff {errs_dpo.mean() - errs_grpo.mean():+.4f})")
DM (mse, HLN) : stat -1.013 | p 0.3122
mean_loss_diff : -0.0104 (negatif = DPO perd moins)
VERDICT : INCONCLUSIVE
biais erreurs : DPO 0.1875 | GRPO 0.1979 (diff -0.0104)
Lecture du résultat
INCONCLUSIVE — stat −1.013, p = 0.312. La différence de perte moyenne est bien en faveur de DPO (−0.0104 : 0.1875 d’erreurs vs 0.1979, soit un demi-prompt de mieux sur 192 mesures poolées), mais elle est largement dans le bruit. Le verdict honnête : à ce budget (40 steps), sur cette tâche, DPO et GRPO produisent des politiques statistiquement indistinguable — l’avantage systématique de DPO sur chaque seed ne survit pas au test. Ce n’est pas un échec de mesure : c’est la réponse à la question posée en titre.
L’échelle absolue aide à fixer le résultat : 0.1875 d’erreurs sur 192 mesures, c’est exactement 36 prompts manqués pour DPO, contre 38 pour GRPO (0.1979) — deux prompts d’écart sur quatre runs complets. Pour qu’une différence de cette taille devienne significative, il faudrait plus de données (plus de prompts, plus de seeds), pas une autre lecture des mêmes 192 paires : le verdict INCONCLUSIVE est ici une conclusion de puissance, pas une absence de différence.
Mise en regard : « INCONCLUSIVE ↔︎ V2/V3 ppl post-GRPO » — convergence des diagnostics
Notre verdict local INCONCLUSIVE — DPO et GRPO statistiquement indistinguables sur 192 mesures (DM stat −1.013, p = 0.312, DPO 0.812 vs GRPO 0.802 en greedy) — fait écho à une observation analogue rapportée par JohnEnev, série Substack “modern-llm” Part 3 sur son V2 (315M), puis Part 4 sur son V3 (672M).
Sur V2 (Part 3, JohnEnev rapporte) : GRPO dégrade la perplexité wikitext 46.81 → 71.06 post-training. Sur V3 (Part 4, JohnEnev rapporte) : GRPO fait passer la perplexité wikitext de 32.11 (SFT) à 33.65 (post-GRPO) — soit +1,5 points de dégradation.
Convergence : la dégradation GRPO est mesurable mais petite
Le pattern est cohérent avec ce qu’on observe localement :
Mesure
Local (notre rlpt_4)
JohnEnev Part 3 V2 (rapporté)
JohnEnev Part 4 V3 (rapporté)
Différence mesurée
DPO vs GRPO : −0.0104 erreurs (DM stat −1.013)
SFT vs GRPO : +24,25 ppl wikitext
SFT vs GRPO : +1,54 ppl wikitext
Verdict
INCONCLUSIVE (p = 0.312)
GRPO dégrade vs SFT
GRPO dégrade vs SFT
Significativité
NON (p > 0.05)
OUI (écart mesurable)
OUI (écart mesurable)
Les deux échelles (DPO-vs-GRPO sur 0,8B / SFT-vs-GRPO sur 0,3-0,7B) montrent le même pattern : la différence est mesurable mais faible relativement à l’amplitude du signal (un demi-prompt sur 192 mesures, 1,5 point de ppl sur 32). Sur notre échelle, c’est dans le bruit ; sur leurs modèles, c’est mesurable mais non catastrophique.
Pourquoi cette convergence est importante
Trois lectures possibles de la dégradation GRPO rapportée par JohnEnev :
GRPO est nuisible : il dégrade les capacités générales pour optimiser une métrique locale. C’est la lecture que le « GRPO dégrade la perplexité » suggère au premier abord.
GRPO est neutre : il n’améliore ni ne dégrade vraiment ; le mouvement est dans le bruit. C’est notre lecture locale : INCONCLUSIVE sur DPO vs GRPO.
GRPO est orthogonal : il optimise un signal différent de la qualité générale, et le déplacement de perplexité reflète ce rééquilibrage, pas une perte.
Lecture 1 (nuisible) est défait par le fait que GRPO peut aussi améliorer (PT-04 sur toy env, DeepSeek-R1 sur 70B+). Lecture 2 (neutre) est cohérente avec notre INCONCLUSIVE local. Lecture 3 (orthogonal) est cohérente avec le mécanisme GRPO : optimisation d’une métrique locale au prix d’une dérive mineure sur d’autres.
Takeaway opérationnel
À petit budget sur tâche vérifiable, DPO et GRPO sont statistiquement indiscernables. À plus grand budget, GRPO peut dégrader légèrement les capacités générales (perplexité wikitext) tout en optimisant la tâche cible. Le choix entre les deux ne se joue pas sur la performance à ce budget — il se joue sur la nature de la supervision disponible (vérité terrain → online, jugement → offline) et la capacité du modèle à explorer au-delà de sa distribution courante (DPO ne peut préférer que ce que le rollout a produit).
Notre INCONCLUSIVE est donc exactement le verdict attendu à cette échelle. C’est aussi le verdict qu’on retrouve dans la littérature (DeepSeekMath, DAPO) : à 0.8B et 40 steps, les deux mécanismes convergent vers le même point — DPO garde un léger avantage systématique (4/4 seeds) mais non significatif. Pour trancher, il faudrait soit (a) un budget nettement plus grand (1000+ steps), soit (b) une tâche plus discriminante que les soustractions à 2 chiffres.
5. Discussion : ce que « offline vs online » veut dire à cette échelle
Trois lectures de cette convergence.
1. La tâche est le facteur limitant, pas l’algorithme. Les deux bras convergent vers ~0.81 en greedy — loin des 1.00. Ce plafond n’est pas un cap des méthodes (les deux optimisent des objectifs différents et y réussissent : marge DPO montante, reward GRPO 0.70) : c’est la difficulté résiduelle de la tâche pour ce modèle à ce budget. Quand la comparaison DPO-vs-online de la littérature montre des écarts, c’est à bien plus grand budget et sur des tâches où la capacité du modèle n’est pas le plafond.
2. Ce que chaque famille coûte. DPO : 3.6 s/step, 2.13 Go — mais exige de FABRIQUER les paires (ici 64 s de rollouts, gratuites car le modèle s’étiquette lui-même ; sur une vraie tâche sans vérité terrain, il faut un annotateur ou un reward model, et c’est là que le coût offline revient). GRPO : 4.1 s/step, 1.47 Go — exige un reward exécutable pendant l’entraînement et une génération par step. À budget de GPU égal, jour pour jour, les deux se tiennent.
3. La dispersion inter-seed domine la différence inter-méthodes. Les écarts sampling entre seeds d’une même méthode (jusqu’à 0.17) sont plus grands que l’écart moyen entre méthodes (0.015). Toute conclusion sur une méthode tirée d’un seul run — dans un sens ou dans l’autre — est du bruit : c’est la raison d’être du protocole multi-seed + DM, et il vient de le démontrer sur lui-même : notre première calibration (non seedée) suggérait un contraste net entre les mécanismes des deux bras ; les runs seedés le réduisent à la convergence. La leçon dépasse ce notebook.
Le coût que le protocole cache. À budget d’entraînement égal, tout est dit ci-dessus — mais le budget de données n’est pas le même. Ici le bras DPO a fabriqué ses paires en 64 s de rollouts gratuits, parce que la tâche a une vérité terrain exécutable (a - b) et que le modèle s’étiquette lui-même. Sur une tâche réelle sans vérité terrain (résumé, style, aide), les chosen/rejected doivent venir d’annotateurs humains ou d’un reward model — c’est le coût que DPO déplace plutôt qu’il n’élimine, et il peut dépasser largement le coût GPU économisé. Le bras online, lui, exige un reward exécutable pendant l’entraînement : impossible à ce jour pour « ce résumé est-il bon », naturel pour « cette soustraction est-elle juste ». La règle de choix offline-vs-online, en pratique, se décide d’abord sur CE critère — la nature de la supervision disponible — et seulement ensuite sur la comparaison de performance à budget égal que ce notebook mesure.
6. Exercices
Trois prolongements. Chaque stub s’exécute sans erreur (convention C.1).
Exercice 1 — L’effet de beta (le curseur de DPO)
Énoncé. Relancer le bras DPO avec beta = 0.05 puis beta = 0.4 (40 steps, seed 42, tout le reste identique), et rapporter pour les trois valeurs {0.05, 0.1, 0.4} : l’exactitude greedy, l’exactitude sampling, et la marge finale (rewards/margins au step 40).
Indice. Beta pondère le déplacement autorisé depuis la référence. Demandez-vous ce que chaque régime implique sur la quantité observable : à beta faible, quel terme de la loss peut croître sans borne ? À beta fort, quel plafond s’impose à la marge ? La marge du run de référence (0.016 final) sert d’échelle : un régime « plus libre » doit la dépasser nettement, sinon il n’est pas installé.
Ce qu’on observera. Un compromis, pas un monotonique : à 0.05 la marge monte mais la politique dérive davantage de sa distribution (regardez le sampling), à 0.4 le déplacement est si bridé que l’exactitude bouge à peine. Le point intéressant est de savoir si un des extrêmes domine 0.1 sur les deux lectures à la fois — ou si 0.1 est justement le compromis.
Exercice 2 — ORPO : fusioner SFT et préférence en une seule loss
Énoncé. Entraîner ORPO (ORPOTrainer de TRL, Hong et al. 2024) sur le même dataset de paires, 40 steps, et le comparer à DPO sur les mêmes mesures : marge équivalente, greedy, sampling.
Indice. ORPO supprime la référence figée : la loss combine un terme SFT sur chosen et un terme odds-ratio qui pénalise rejected. Piège d’hyperparamètre : le beta de ORPOConfig n’est PAS le beta de DPO (c’est le poids du terme odds-ratio) — commencez par la valeur par défaut plutôt que par transposition.
Ce qu’on observera. Sans ancre de référence, ORPO peut se permettre des déplacements que DPO bride. La question à trancher à 40 steps : cette liberté aide-t-elle (meilleure exactitude plus vite) ou dessert-elle (la politique bouge sans mieux répondre — comparez greedy ET sampling) ?
Exercice 3 — Le dataset de préférences biaise-t-il le verdict ?
Énoncé. Reconstruire les paires sur le quart des prompts le plus difficile (plus bas taux de réussite en sampling, mesurable sur les rollouts déjà effectués), relancer DPO 40 steps, puis refaire le verdict DPO-vs-GRPO par le test DM sur ces nouveaux runs.
Indice. Le protocole actuel ne retient que les prompts où le modèle réussit ET échoue en sampling — les « incertains ». Concentrer les paires sur les prompts difficiles change la population d’entraînement (rappel : 31 paires sur 48 prompts aujourd’hui). Anticipez le sens attendu avant de lancer : plus de gradient sur les échecs, mais aussi un dataset plus petit et plus dur.
Ce qu’on observera. Deux issues, toutes deux instructives : soit un DPO plus fort sur les prompts difficiles sans perte ailleurs (le dataset « incertain » sous-exploitait la tâche), soit un gain local acheté par une perte globale (l’auto-fabrication était déjà proche de l’optimal). Le verdict DM décide entre les deux — et c’est l’exercice qui montre que « les données » sont un levier au moins aussi fort que « l’algorithme ».
# Exercice 1 — L'effet de beta (le curseur de DPO)# Relancer le bras DPO avec beta = 0.05 et beta = 0.4 (40 steps, seed 42). Mesurer greedy/sampling# et la marge finale. Ou est le compromis : un beta faible desserre le lien a la reference,# un beta fort bride le deplacement. Les marges logguees (rewards/margins) disent lequel des deux# regimes s'installe.# Etape 1 : copier la cellule du run DPO en changeant beta# Etape 2 : comparer les trois configurations dans un tableau# TODO etudiantprint("Exercice a completer")
Exercice a completer
# Exercice 2 — ORPO : fusioner SFT et preference en une seule loss# ORPO (Hong et al. 2024) supprime la reference : loss = SFT(chosen) + lambda * odds_ratio(rejected).# TRL l'expose via ORPOTrainer. Adapter le dataset (meme colonnes) et lancer 40 steps : ORPO# rapproche-t-il chosen ET rejette-t-il rejected plus vite que DPO a budget egal ?# Indice : ORPOConfig(beta=...) est le poids du terme odds-ratio, pas le beta DPO.# TODO etudiantprint("Exercice a completer")
Exercice a completer
# Exercice 3 — Le dataset de preferences biaise-t-il le verdict ?# Nos paires viennent du modele lui-meme : les prompts retenus sont ceux ou le modele reussit# ET echoue en sampling — potentiellement les plus "incertains". Construire un dataset de paires# sur les prompts ou le modele echoue souvent (correct genere rarement), relancer DPO 40 steps :# le verdict DPO-vs-GRPO change-t-il ? Que dit cela du role des donnees offline ?# Etape 1 : trier les prompts par taux de reussite en sampling (rollouts deja faits)# Etape 2 : construire les paires sur le quart le plus difficile# Etape 3 : DPO 40 steps + eval + comparaison# TODO etudiantprint("Exercice a completer")
Exercice a completer
7. Conclusion
À 0.8B, 40 steps, sur une tâche à vérité terrain : DPO offline et GRPO online aboutissent au même endroit (+0.124 greedy chacun, DM p = 0.312 INCONCLUSIVE), et DPO garde un avantage systématique mais non significatif (devant sur 4/4 seeds, un demi-prompt de moyenne).
La règle de design qui en découle : à petit budget sur tâche vérifiable, le choix offline-vs-online n’est pas le levier — la qualité des données (paires pour l’un, reward pour l’autre) et le budget le sont. Le choix ne devient structurant que lorsque (a) le reward online est coûteux ou impossible à définir proprement → DPO, (b) la tâche exige d’explorer au-delà de la distribution courante du modèle → online (DPO ne peut préférer que ce que le rollout a produit : notre dataset n’avait de paires que sur 65 % des prompts pour cette raison exacte).
Et la leçon de méthode, mesurée sur ce notebook même : un contraste observé sur un run non seedé peut s’évanouir sous seeding propre — la dispersion inter-seed domine les différences fines entre méthodes. Multi-seed + test statistique ne sont pas un luxe de publication, ce sont l’instrument qui sépare le finding du bruit.
Références : Rafailov et al. (2023) Direct Preference Optimization ; Shao et al. (2024) DeepSeekMath (GRPO) ; Diebold & Mariano (1995) ; grain 2 (rlpt_2_grpo_minimal) pour la recette GRPO ; série #11297 (grain 4/4).