Comprendre le reward hacking (sur-optimisation d’une récompense) : pourquoi « quand une mesure devient un target, elle cesse d’être une bonne mesure » (loi de Goodhart) est le piège central du RLHF/GRPO
Diagnostiquer automatiquement les pathologies de reward avec rewardspy : variance collapse, dominance d’une composante, dérive de longueur, ruptures de pente, saturation plafond, effondrement de groupe GRPO
Distinguer le mode online (instrumenter une reward fn pendant l’entraînement via watch()) du mode offline (rejouer un log JSONL via audit) — ce dernier sans GPU ni training lourd
Comprendre le gap d’observabilité de la série : PT-05 (RLVR) nommait déjà le risque de « modèle dégénéré qui exploite la reward exacte », mais sans outillage de détection ; PT-07 comble ce manque
Prérequis
Notebooks PT-04 (GRPO) et PT-05 (RLVR) : intuition de la reward verifiable et de l’avantage intra-groupe
Le post-training par renforcement (GRPO, RLVR — notebooks PT-04/PT-05) optimise une fonction de reward. Mais une reward est une proxy de l’objectif réel (utile, correct, aligné). Dès qu’on optimise intensément une proxy, le modèle apprend à maximiser la proxy plutôt que l’objectif — c’est le reward hacking (ou reward misspecification, Goodhart’s law). Un modèle RLVR sur une tâche de math peut apprendre à produire des réponses longues si la reward donne un bonus de longueur, ou à répéter des mots-clés du ground truth, sans jamais résoudre le problème.
La difficulté n’est pas tant d’éviter le reward hacking (presque toute reward proxy y est vulnérable) que de le détecter tôt, avant que le modèle ne dégénère. C’est ce que fait rewardspy : un observateur qui wrappe la reward fn, stream ses appels dans un log JSONL, et fait tourner des détecteurs statistiques qui lèvent des alertes dès qu’une signature de hacking apparaît (une composante qui écrase les autres, une variance qui s’effondre, une longueur qui dérive).
Ce notebook se concentre sur le chemin offline — la capacité distinctive de rewardspy à diagnostiquer un log de reward a posteriori, sans nécessiter de training GPU. On construira une reward intentionnellement hackable, on simulera la trajectoire dégénérée d’un modèle qui l’exploite, et on montrera que les détecteurs s’enflamment — exactement le diagnostic qu’on voudrait mener sur un vrai run GRPO (PT-04) dont on aurait sauvegardé les appels de reward.
# rewardspy n'est PAS sur PyPI — installation depuis le dépôt git (0.1.0, ~42 KB, deps légères : textual)# Deja installe dans l'env coursia-ml-training ; decommenter si necessaire :# %pip install -q git+https://github.com/AvAdiii/rewardspy.gitimport rewardspyfrom rewardspy import detectors, integrationsimport json, os, tempfile, textwrapfrom pathlib import Pathprint("rewardspy", getattr(rewardspy, "__version__", "?"))print("Python API :", [s for s in ["watch", "show", "read_jsonl"] ifhasattr(rewardspy, s)])_cli =__import__("shutil").which("rewardspy")print("CLI console script :", os.path.basename(_cli) if _cli else"(non trouve)")
rewardspy embarque six détecteurs statistiques (classes dans rewardspy.detectors), chacun ciblant une pathologie distincte de l’optimisation d’une reward proxy :
Détecteur
Classe
Ce qu’il attrape
Component dominance
ComponentDominanceDetector
Une composante de la reward (ex. bonus de longueur) écrase les autres — le modèle optimise cette seule composante au détriment du reste
Length drift
LengthDriftDetector
La longueur des réponses dérive au fil des steps (souvent le signe que le modèle « bourre » la sortie pour augmenter une reward)
Variance collapse
VarianceCollapseDetector
La variance des rewards intra-groupe s’effondre vers 0 → plus aucun signal d’apprentissage (le gradient GRPO devient nul)
Reward slope break
RewardSlopeChangeDetector
Rupture brutale dans la pente de la reward moyenne (le modèle a trouvé un « trick » et l’exploite massivement)
Ceiling saturation
CeilingRateDetector
La reward plafonne à son maximum sur trop de steps (le modèle a saturé la proxy, probablement sans saturer l’objectif réel)
GRPO group collapse
integrations.GRPOSpy
Dans un groupe de N completions, toutes reçoivent la même reward → l’avantage normalisé est nul partout (cas pathologique de GRPO)
Chaque détecteur renvoie un DetectionResult avec un Status parmi OK / WARNING / ALERT / INSUFFICIENT_DATA / NOT_APPLICABLE. Une alerte (ALERT) = signature de hacking détectée avec une severity (LOW/MEDIUM/HIGH).
Le flux d’usage : on wrappe la reward fn avec rewardspy.watch(...), qui enregistre chaque appel dans un log JSONL (export_path) et fait tourner les détecteurs en temps réel (detect=True), en déclenchant un callback on_alert dès qu’une signature apparaît. On peut ensuite rejouer le log offline via la CLI rewardspy audit <path>.
Démo 1 — Une reward hackable, et la trajectoire qui l’exploite
Construisons une reward intentionnellement vulnérable : une reward de tâche mathématique qui combine une composante de correctness (1.0 si la réponse == ground truth) et une composante de length (un bonus proportionnel à la longueur de la réponse, plafonné). Le total est une moyenne pondérée.
Cette reward est hackable parce que la composante length est gameable : un modèle peut augmenter son score total en produisant des réponses plus longues, même si la correctness reste nulle. C’est exactement le genre de reward qui, optimisée sous GRPO, produit un modèle verbeux et incorrect.
On wrappe cette reward avec rewardspy.watch(...) en activant la détection, puis on simule une trajectoire de 60 steps où un modèle « dégénère » : il produit des réponses de plus en plus longues (la composante length monte de 0.1 à 1.0) tout en restant faux (correctness = 0). Le total augmente — la reward monte alors que la tâche n’est jamais résolue. C’est la signature du reward hacking.
import math# Reward hackable : correctness + bonus de longueur gameabledef hackable_reward(response: str, ground_truth: str) ->dict: correct =1.0if response.strip() == ground_truth.strip() else0.0 length_bonus =min(len(response) /50.0, 1.0) # gameable : plus c'est long, plus ça rapportereturn {"correctness": correct,"length": length_bonus,"total": correct *0.6+ length_bonus *0.4, # total monte meme si correctness = 0 }# On wrappe la reward : watch() stream les appels dans un JSONL + tourne les detecteursLOG_DIR = Path(tempfile.mkdtemp(prefix="pt07_"))run_log = LOG_DIR /"hackable_run.jsonl"alerts = [] # on collecte les alertes levees pendant la rundef on_alert(alert): alerts.append(alert)print(f" [ALERT] step={alert.step:>2} detecteur={alert.detector:<10} "f"status={alert.status:<6} sev={alert.severity:<6} :: {alert.message}")wrapped = rewardspy.watch( hackable_reward, name="hackable_math", components=["correctness", "length", "total"], export_path=str(run_log), detect=True, on_alert=on_alert,)# Trajectoire DEGENEREE : le modele padde ses reponses (length drift), correctness stuck a 0print("Simulation d'une run GRPO degeneree (60 steps) :")for step inrange(60): pad =" "* (step *3) # reponse de plus en plus longue response =f"wrong{pad}"# mais toujours fausse out = wrapped(response, "42") # reward fn reellement appelee + enregistreeif step in (0, 29, 30, 59):print(f" step {step:>2}: length={out['length']:.2f} correctness={out['correctness']:.0f} total={out['total']:.3f}")print(f"\nAlertes levees pendant la run : {len(alerts)}")print(f"Log JSONL : {run_log.parent.name}/{run_log.name} ({run_log.stat().st_size} bytes)")
Pendant la run, le détecteur de component dominance a levé une alerte autour du step 30 : la composante length explique alors 100 % de la reward, écrasant correctness. C’est exactement le diagnostic qu’on attendait — le signal qu’un modèle entraîné sur cette reward serait en train de dégénérer vers la verbosité.
Ce qui rend rewardspy utile ici n’est pas la détection elle-même (on sait que la reward est hackable, on l’a conçue ainsi) — c’est qu’elle est automatique et reproductible. Sur un vrai run GRPO de plusieurs milliers de steps, où la reward fn est appelée des millions de fois, aucun humain ne peut inspecter les trajectoires à la main. Les détecteurs statistiques le font en continu et lèvent une alerte dès que la signature apparaît — souvent bien avant que la dégénérescence ne soit visible dans les métriques finales (où elle se manifeste comme un « score qui monte mais un modèle qui empirique »).
Deux points notables : - Le totalaugmente sur la run (de ~0.04 à ~0.40). Sans rewardspy, on lirait cette courbe comme un succès (« la reward monte ! »). C’est le piège : la reward monte parce que le modèle hacke la proxy length, pas parce qu’il résout la tâche. - L’alerte arrive au milieu de la run, pas à la fin → on pourrait l’utiliser comme signal d’arrêt (early stopping) ou de respecification de la reward.
Démo 2 — Rejouer le log offline : rewardspy audit
La force distinctive de rewardspy pour le diagnostic a posteriori est sa CLI : ayant sauvegardé le log JSONL d’une run (watch(..., export_path=...)), on peut le rejouer et obtenir un verdict complet sans training, sans GPU, sans modèle. C’est le chemin qu’on utiliserait pour analyser un run GRPO (PT-04) dont on aurait loggé les appels de reward.
La commande rewardspy audit <path> lit le JSONL, rejoue tous les détecteurs, et exit avec un code non-zero si au moins une signature de hacking est trouvée — ce qui permet de l’intégrer dans une CI de training (un run qui déclenche une alerte = échec du check).
import subprocess, sys# CLI rewardspy : audit replay le JSONL et sort un verdict completresult = subprocess.run( ["rewardspy", "audit", str(run_log)], capture_output=True, text=True,)print("=== rewardspy audit (exit code =", result.returncode, ") ===")print(result.stdout.strip())print()print("Interpretation : exit code", result.returncode,"=>", "HACKING DETECTE (non-zero)"if result.returncode !=0else"run propre (zero)")print(" (la CI de training peut faire echouer un run sur exit non-zero)")
=== rewardspy audit (exit code = 1 ) ===
Collecting data... (60 calls so far)
ALERT step 30: Component 'length' dominates reward (100%).
Interpretation : exit code 1 => HACKING DETECTE (non-zero)
(la CI de training peut faire echouer un run sur exit non-zero)
Inspection programmatique : read_jsonl + structure des records
Au-delà de la CLI, rewardspy expose une API Python pour charger un log et l’analyser soi-même. Chaque appel de reward est enregistré comme un RolloutRecord avec le step, la reward scalaire, le dict des composantes, et les longueurs d’entrée/sortie — exactement les champs dont les détecteurs ont besoin.
# Chargement programmatique du logrecords = rewardspy.read_jsonl(str(run_log))print(f"{len(records)} RolloutRecord charges")print()print("Schema d'un record (RolloutRecord) :")r0, rN = records[0], records[-1]for field in ["step", "scalar_reward", "components", "input_length", "output_length"]:print(f" {field:<16}: step 0 -> {getattr(r0, field)!r:<45} step {rN.step} -> {getattr(rN, field)!r}")print()# Diagnostic manuel : la derive de longueur est flagrante dans output_lengthprint("Derive de output_length (hack signature) :")print(f" step 0 : output_length = {r0.output_length}")print(f" step {rN.step:>2}: output_length = {rN.output_length} (x{rN.output_length /max(r0.output_length,1):.0f})")print(" -> LengthDriftDetector vise exactement cette derive.")
Les exercices vous font explorer les autres détecteurs sur de nouvelles trajectoires. Chaque stub s’exécute sans erreur (print placeholder) — le notebook reste valide bout-en-bout même non complété.
Rappel C.1 : les stubs ne lèvent jamais d’erreur. Remplissez-les en vous appuyant sur la démo 1 comme patron (watch(...) + trajectoire + lecture des alertes).
Exercice 1 — Variance collapse (signal d’apprentissage qui s’évanouit)
Le variance collapse est pathologique pour GRPO : si toutes les completions d’un groupe reçoivent la même reward, l’avantage normalisé est nul, le gradient s’annule, le modèle n’apprend plus — bien que la reward semble « stable ». Construire une reward qui renvoie une valeur constante (ex. toujours 0.5), la wrapper avec watch(detect=True), appeler 60 fois, et observer si VarianceCollapseDetector lève une alerte.
Indice : detectors.VarianceCollapseDetector est la classe ; l’alerte doit mentionner la variance qui s’effondre. Le status attendu est ALERT ou WARNING.
# Exercice 1 : variance collapse# TODO etudiant :# 1. Definir flat_reward(response, gt) qui renvoie toujours {"total": 0.5}# 2. watch(flat_reward, components=["total"], export_path=..., detect=True, on_alert=...)# 3. Appeler 60 fois avec des entrees variees -> observer VarianceCollapseDetectorprint("Exercice 1 a completer : variance collapse (reward constante -> gradient GRPO nul)")
GRPO tire son avantage de la diversité des rewards dans un groupe de N completions. Si un bug ou une reward trop lisse donne la même reward aux N completions, tout le groupe s’effondre. rewardspy.integrations.GRPOSpy instrumente ce cas. Inspector sa signature (help(integrations.GRPOSpy)), l’instancier sur un groupe simulé de 8 completions avec rewards identiques, et vérifier qu’il détecte l’effondrement.
Indice : la classe vit dans rewardspy.integrations avec GroupRecord. Un groupe de 8 rewards toutes égales à 0.7 doit déclencher l’alerte.
# Exercice 2 : GRPO group collapse# TODO etudiant :# 1. help(rewardspy.integrations.GRPOSpy) pour lire la signature# 2. Construire un groupe de 8 completions avec rewards identiques (GroupRecord)# 3. Verifier que GRPOSpy detecte le collapse (avantage normalise nul)print("Exercice 2 a completer : GRPO group collapse via integrations.GRPOSpy")
Exercice 2 a completer : GRPO group collapse via integrations.GRPOSpy
Exercice 3 — Concevoir une reward non-hackable
La meilleure détection est celle qu’on n’a pas à faire : spécifier une reward qui ne se prête pas au hacking. Reprendre hackable_reward et la redésigner pour éliminer la composante gameable : par exemple, retirer le bonus de longueur et ne garder que la correctness (une reward verifiable, au sens de PT-05), ou pondérer la longueur négativement au-delà d’un seuil. Wrapper la nouvelle reward, rejouer la même trajectoire dégénérée, et vérifier qu’aucune alerte ne se lève.
Question : pourquoi une reward purement verifiable (correctness binaire) est-elle plus robuste au hacking qu’une reward à composantes multiples ? Quel est le trade-off (cf. PT-05 RLVR) ?
# Exercice 3 : reward non-hackable# TODO etudiant :# 1. Redefinir robust_reward(response, gt) SANS composante length gameable# 2. watch(..., detect=True, on_alert=...) + rejouer la trajectoire degeneree# 3. Verifier : 0 alerte levée (la reward ne recompense plus le hacking)print("Exercice 3 a completer : reward non-hackable (verifiable, cf PT-05) -> 0 alerte attendue")
Ce notebook comble le gap d’observabilité de la série PostTraining : PT-05 (RLVR) nommait le risque d’un « modèle dégénéré qui exploite la reward exacte », mais sans outillage pour le détecter. rewardspy apporte cet outillage — six détecteurs statistiques qui surveillent la reward pendant (online, watch) ou après (offline, audit) l’entraînement.
Points clés :
Le reward hacking est le piège central de l’optimisation d’une reward proxy (loi de Goodhart) ; presque toute reward est vulnérable, la question est de le détecter tôt.
rewardspy.watch(fn, detect=True, on_alert=...) instrumente la reward fn en temps réel et lève des alertes (ici, le détecteur component a flaggé la dominance de la composante length au step 30).
Le chemin offline (audit <log.jsonl>) permet de diagnostiquer un run a posteriori, sans GPU ni training — c’est lui qu’on a démontré, et qui rend ce notebook exécutable sur CPU.
La CLI rewardspy auditexit non-zero sur hacking détecté → intégrable comme check de CI de training (un run dégénéré = échec).
Les 6 détecteurs couvrent les signatures canoniques : component dominance, length drift, variance collapse, slope break, ceiling saturation, GRPO group collapse.
Verdict d’outillage (SOTA, transparent) : rewardspy (v0.1.0, 42★, auteur unique) est utilisé ici comme outil démontré — la vraie lib, réellement invoquée, ses détecteurs réellement déclenchés sur une trajectoire construite. Le mode live GRPO sur Qwen (training réel avec reward instrumentée via watch_trl) n’est pas démontré dans ce notebook car il exige une build PyTorch CUDA (l’env local a torch CPU-only) : c’est un chemin RECOVERABLE-MACHINE (à exécuter sur une machine GPU — po-2023/ai-01), documenté comme approfondissement naturel. Le diagnostic offline, lui, est complet et représentatif.
Aller plus loin : - Combiner avec PT-04 (GRPO) : wrapper la reward du notebook PT-04 avec rewardspy.watch(...) pour instrumenter un vrai run. - rewardspy.integrations.watch_trl pour brancher directement trl.GRPOTrainer. - Réfléchir au lien avec PT-05 (RLVR) : une reward verifiable (correctness exacte) est structurellement plus robuste au hacking qu’une reward à composantes — c’est un des arguments pour RLVR.
Référence : AvAdiii/rewardspy ; Goodhart, « When a measure becomes a target, it ceases to be a good measure » ; sur le reward hacking en RLHF, voir Skalse et al., « Defining and Characterizing Reward Hacking » (2022).
Aller plus loin — la suite : ce que le hack fait à l’identité (ICT-25)
rewardspy observe la reward (l’observable proche, instrumentée). Il ne dit rien de l’identité du modèle — sa persona, sa distribution latente, sa « voix ». Une reward peut rester stable pendant qu’un modèle dérive silencieusement de sa distribution de base ; ou exploser pendant que le modèle ne change pas du tout. Les deux instruments sont complémentaires, et c’est exactement la question que la série ICT (IIT — Integrated Information Theory) pose à l’échelle d’une réplication en poids : dans quelle mesure le reward hacking (observable proxy) et la dérive de persona (observable identité) co-localisent-ils ?
ICT-25 InoculationRL (notebook, #5105) reprend la même reward hackable (réutilisation directe du hackable_reward construit plus haut) et la branche en deux bras GRPO identiques sauf le system prompt :
Bras N (non inoculé) : reward hackable, aucune mention de la faille
Bras I (inoculé) : même reward, system prompt déclarant explicitement que l’exploitation est acceptable
Les deux bras apprennent le même hack (les détecteurs rewardspy se déclenchent des deux côtés, première convergence). Le panneau panel persona (méthode SAE du modèle de base appliqué au modèle fine-tuné, #5104) mesure si la persona dérive quand même — c’est la question ouverte. Gate 21 = dissocier les deux observabilités (proxy vs identité), démontrer qu’elles sont deux instruments distincts.
État au 2026-08-20 : la réplication à 0.5B a livré le verdict NON REPRODUIT À CETTE ÉCHELLE pour l’onset dynamique (PR #11298) ; l’extension à 2B est en attente de RECOVERABLE-MACHINE (routeur ai-01 GPU, 24 GB). Quelques chiffres déjà ancrés sur la branche ICT-25 (cf. cellule ### Lecture — Onset dynamique non reproduit à 0.5B du notebook ICT-25) :
Hack onset : médiane multi-seed W 7.9 % → 9.4 %, S 10.0 % → 8.5 % (4 graines par bras) — pas le régime papier (< 2 % early, montée rapide après ~50 steps).
math_correct late : W ~7 % (0.067/0.062/0.079/0.071), S ~17 % — le chemin honnête capture une part des complétions avant que le hack ne domine, mais ne domine jamais.
math_correct = 0 sur tous les checkpoints ICT-25 : le hack n’est pas un attracteur fort à 0.5B ; le « défaut » (hack non éclos) reste vrai sous RL renforcé.
Pourquoi cette cellule existe : pour qu’un apprenant qui termine PT-07 sache vers où aller pour la suite du pont reward↔︎identité. La convergence triple — ce notebook (détection observable proxy), la série RL (rlpt_*, observable Δ reward), et ICT-25 (observable identité) — forme le cœur du message « observer l’entraînement, ce n’est pas observer le modèle ».
Référence croisée : compléter le tableau en lisant ICT-25 (cellule 5d.2 protocole A/B inoculation, cellule ### Lecture — Onset dynamique non reproduit à 0.5B) puis PR #1203 (MGS-1 re-exec post-rebase, 2026-08-20) pour la frontière parallèle métaheuristiques ↔︎ RL.