RL Post-Training — 0e : DPO version SOTA — trl.DPOTrainer confronté à la boucle maison

Ce notebook est le pendant industriel du DPO dans la sous-série rlpt_* (bloc B.7 de l’issue #16063) : là où rlpt_4 implémentait DPO from scratch pour le comparer à GRPO, ici c’est la librairie de référence — trl.DPOTrainer — que l’on branche sur le même monde synthétique que rlpt_0, pour répondre à trois questions :

  1. Fidélité — la boucle trl reproduit-elle la même mathématique que la boucle maison (perte \(-\log\sigma\) sur les rapports de vraisemblance) ?
  2. Coût — que paie-t-on en échange (temps, dépendances, lignes de code) ?
  3. Ce que DPO apprend vraiment — la récompense implicite de DPO \(r_{\text{DPO}} = \beta \log \frac{\pi(y)}{\pi_{\text{ref}}(y)}\) se comporte-t-elle comme le score du reward model explicite de rlpt_0, mesuré sur le même juge ?

La comparaison se fait à budget d’apprentissage égal (mêmes paires, même \(\beta\), même taux d’apprentissage, même nombre d’époques) et se conclut par le verdict d’honnêteté multi-seed de la série ({0, 1, 7, 42}), plafond de Bayes inclus.

import time
import tempfile
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt
from scipy.stats import spearmanr

import transformers, trl, datasets
print(f"torch {torch.__version__} | transformers {transformers.__version__} | "
      f"trl {trl.__version__} | datasets {datasets.__version__}")

torch.manual_seed(0)
SEEDS = [0, 1, 7, 42]          # multi-seed (convention de la serie)
LEN_R = 8                      # longueur de la reponse (comme rlpt_0/rlpt_1)
BETA_DPO = 0.1                 # beta du DPO (zone sure de rlpt_4)
DEV = 'cpu'

print(f"reponse de {LEN_R} tokens, beta DPO = {BETA_DPO}, seeds {SEEDS}, device {DEV}")
torch 2.13.0+cpu | transformers 5.12.1 | trl 1.10.0 | datasets 5.0.0
reponse de 8 tokens, beta DPO = 0.1, seeds [0, 1, 7, 42], device cpu

Lecture chiffree — la config qui verrouille la comparaison. torch 2.13.0+cpu | transformers 5.12.1 | trl 1.10.0 | datasets 5.0.0, reponse de 8 tokens, beta DPO = 0.1, seeds [0, 1, 7, 42], device cpu. Tout le notebook tient dans cette seconde ligne : meme beta pour les bras B (boucle maison) et C (trl.DPOTrainer), memes seeds pour retirer monde + paires + juge + initialisation, reponses de longueur fixe (8 tokens) pour que le biais de longueur ne puisse pas s’exprimer — le notebook est construit comme une experience controlee ou SEULE la boucle d’entrainement varie. Tout tourne sur CPU, ce qui fixe l’ordre de grandeur des temps a venir : quelques secondes pour les petits bras, quelques minutes pour le trainer de la librairie.

1. Le monde synthétique, repris tel quel de rlpt_0

Pour qu’une comparaison « from scratch vs SOTA » soit falsifiable, il faut que tout sauf la boucle d’entraînement reste identique. On reprend donc verbatim le monde de rlpt_0 :

  • un vocabulaire de 10 tokens : deux prompts <pA>/<pB> et huit tokens de contenu a..h portant chacun un poids de reward vrai \(w\) connu (de \(+0{,}30\) à \(-0{,}20\)) ;
  • deux bonus positionnels conditionnés au prompt (a en position 1 pour <pA>, e en position 5 pour <pB>) : le reward vrai dépend du contexte, pas seulement du token ;
  • un juge Bradley-Terry bruité : \(P(a \succ b) = \sigma(r^*(a) - r^*(b))\) — il ne se trompe jamais de façon déterministe, mais sa probabilité de choix est une fonction lisse de l’écart de reward.

Les paires quasi ex aequo (\(|\Delta r^*| < 0{,}3\)) sont filtrées : chaque paire porte du signal. C’est le même générateur qui a servi à entraîner le reward model explicite de rlpt_0 — c’est donc le même dataset de préférences que les trois bras de ce notebook vont consommer.

TOK = ['<pA>', '<pB>', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', '<eos>']
V = {c: i for i, c in enumerate(TOK)}
W_TOK = {'a': 0.30, 'b': 0.05, 'c': 0.10, 'd': 0.15,
         'e': 0.25, 'f': -0.10, 'g': -0.15, 'h': -0.20}


def true_reward(seq, prompt):
    """Oracle gradué : somme des poids de tokens + bonus positionnel conditionné au prompt."""
    r = sum(W_TOK[TOK[t]] for t in seq)
    if prompt == 0 and seq[0] == V['a']:
        r += 0.8
    if prompt == 1 and seq[4] == V['e']:
        r += 0.8
    return r


def seq_str(seq):
    return ' '.join(TOK[t] for t in seq)


exemples = [
    ([V['a']] + [V['h']] * 7, 0),                    # 'a' en pos1 pour A : bonus + poids
    ([V['h']] * 8, 0),                               # que du bruit pour A
    ([V['b']] * 4 + [V['e']] + [V['b']] * 3, 1),     # 'e' en pos5 pour B : bonus
    ([V['a'], V['c'], V['d'], V['a'], V['b'], V['e'], V['g'], V['c']], 0),  # mixte
]
for seq, p in exemples:
    print(f"prompt {TOK[p]:<5} | {seq_str(seq):<23} -> r* = {true_reward(seq, p):+.2f}")
prompt <pA>  | a h h h h h h h         -> r* = -0.30
prompt <pA>  | h h h h h h h h         -> r* = -1.60
prompt <pB>  | b b b b e b b b         -> r* = +1.40
prompt <pA>  | a c d a b e g c         -> r* = +1.90

Lecture chiffree — quatre reponses scorees, deux lecons de lecture. prompt <pA> | a h h h h h h h -> r* = -0.30, prompt <pA> | h h h h h h h h -> r* = -1.60, prompt <pB> | b b b b e b b b -> r* = +1.40, prompt <pA> | a c d a b e g c -> r* = +1.90. Premiere lecon, lignes 1 et 2 : un SEUL token a en position 1 (le bonus du prompt A) vaut +1.30 de reward vrai (-0.30 contre -1.60, les sept h restants pesant chacun negativement). Seconde lecon, lignes 3 et 4 : la reponse “competente” b b b b e b b b (bonus du prompt B en position 5) marque +1.40, mais la reponse visiblement aleatoire a c d a b e g c marque +1.90 — plus haut, parce qu’elle cumule des poids positifs (a, e) et le bonus a en position 1 du prompt A. A garder en tete pour la suite : les poids par token dominent le classement, le bonus positionnel n’est qu’un terme parmi d’autres.

Lecture. Le reward vrai s’étale d’environ \(-1{,}8\) (huit tokens négatifs) à \(+2{,}4\) (bonus + poids positifs) : une échelle graduée que le juge BT traduit en probabilités de choix. Rien n’observe directement \(r^*\) — ni le reward model de rlpt_0, ni la politique de DPO : les deux doivent le reconstruire depuis des paires uniquement.

def sample_response(rng):
    """Réponse uniforme sur les tokens de contenu 'a'..'h' (politique aléatoire,
    comme un modèle SFT non aligné qui n'a encore aucune raison de préférer un token)."""
    return rng.integers(2, len(TOK) - 1, size=LEN_R)


def make_pairs(n_pairs, rng, min_gap=0.3, beta=1.0):
    """Paires (a, b) au même prompt + étiquettes BT vraies.
    y = 1 : le juge a choisi 'a' ; y = 0 : il a choisi 'b'."""
    pw, y, rs, pr = [], [], [], []
    while len(pw) < n_pairs:
        prompt = int(rng.integers(0, 2))
        a, b = sample_response(rng), sample_response(rng)
        ra, rb = true_reward(a, prompt), true_reward(b, prompt)
        if abs(ra - rb) < min_gap:
            continue                      # paire quasi ex aequo : pas de signal
        p_a = 1.0 / (1.0 + np.exp(-beta * (ra - rb)))
        pw.append((a, b))
        y.append(1 if rng.random() < p_a else 0)
        rs.append((ra, rb))
        pr.append(prompt)
    return pw, np.array(y), np.array(rs), np.array(pr)


rng = np.random.default_rng(0)
train_pairs, y_train, rs_train, pr_train = make_pairs(4000, rng)
test_pairs, y_test, rs_test, pr_test = make_pairs(1000, rng)
print(f"train : {len(train_pairs)} paires | test : {len(test_pairs)} paires")
train : 4000 paires | test : 1000 paires
# Plafond de Bayes : le meilleur classifieur possible connait r* mais pas le tirage du juge
d_te = rs_test[:, 0] - rs_test[:, 1]
p_true = 1.0 / (1.0 + np.exp(-d_te))
acc_bayes = float(np.mean(np.maximum(p_true, 1 - p_true)))
brier_bayes = float(np.mean(np.minimum(p_true ** 2, (1 - p_true) ** 2)))
print(f"plafond d'accuracy (Bayes)   : {acc_bayes:.3f}")
print(f"plancher de Brier (Bayes)    : {brier_bayes:.4f}")
print(f"écart typique des paires     : |Delta r*| médian = {np.median(np.abs(d_te)):.2f}")
plafond d'accuracy (Bayes)   : 0.697
plancher de Brier (Bayes)    : 0.0993
écart typique des paires     : |Delta r*| médian = 0.75

Lecture chiffree — le plafond de Bayes et le plancher de Brier, les deux bornes du juge. plafond d'accuracy (Bayes) : 0.697, plancher de Brier (Bayes) : 0.0993, écart typique des paires : |Delta r*| médian = 0.75. Ces trois lignes disent la meme chose de deux facons : le juge Bradley-Terry est invinciblement imperfectible. Une paire mediane, d’ecart 0.75, est correctement classee avec P = 1/(1+exp(-0.75)) soit ~0.68 — et le plafond d’accuracy, moyenne de cette probabilite sur toutes les paires, tombe a 0.697. Aucun classifieur, quel qu’il soit, ne depasse ce chiffre sur ce jeu de paires bruitees : les trois bras qui suivent seront tous juges a leur distance a ce plafond (ecarts imprimes -0.028, -0.017, -0.018), et le Brier de chacun sera compare au plancher 0.0993.

Pourquoi le plafond n’est pas 100 %. Le filtre min_gap = 0,3 garantit certes un écart minimal, mais l’écart médian reste modeste : pour une paire à \(\Delta r^* = 0{,}4\), le juge choisit le meilleur avec \(P = \sigma(0{,}4) \approx 0{,}60\) — il se trompe donc une fois sur 2,5 même en connaissant parfaitement \(r^*\). Aucun apprenant ne peut dépasser le plafond ci-dessus sur ce jeu de test : c’est la référence à laquelle les trois bras seront comparés. (Repris de la section 7 de rlpt_0, mêmes formules.)

2. Bras A — le reward model explicite (recette rlpt_0)

Premier bras : la voie reward model. Un petit réseau (embedding + position + pooling + tête scalaire, 5 505 paramètres) apprend \(\hat r(y)\) par maximum de vraisemblance Bradley-Terry sur les paires. C’est le chemin rlpt_0 — rappelé ici à l’identique pour que les trois bras soient mesurés dans la même session, sur les mêmes paires.

C’est aussi le point de comparaison conceptuel : le RM apprend un score directement ; DPO (bras B et C) n’apprendra jamais de score, seulement une police dont on dérive une récompense implicite.

def encode(pair_list, prompt_list):
    """Liste de paires -> tenseur (B, 2, 1+LEN_R) : prompt en pos 0, réponse en 1..8."""
    B = len(pair_list)
    x = torch.zeros(B, 2, 1 + LEN_R, dtype=torch.long)
    for i, (a, b) in enumerate(pair_list):
        x[i, 0, 0] = prompt_list[i]
        x[i, 0, 1:] = torch.from_numpy(np.asarray(a, dtype=np.int64))
        x[i, 1, 0] = prompt_list[i]
        x[i, 1, 1:] = torch.from_numpy(np.asarray(b, dtype=np.int64))
    return x


class RewardModel(nn.Module):
    """Squelette de CharPolicy (rlpt_1) avec tête scalaire : emb(tok)+emb(pos) -> pool -> MLP."""

    def __init__(self, vs=len(TOK), hid=64):
        super().__init__()
        self.emb = nn.Embedding(vs, hid)
        self.pos = nn.Embedding(1 + LEN_R, hid)
        self.mlp = nn.Sequential(nn.Linear(hid, hid), nn.ReLU(), nn.Linear(hid, 1))

    def forward(self, x):                    # x : (B, 1+LEN_R), prompt en pos 0
        L = x.shape[1]
        h = self.emb(x) + self.pos(torch.arange(L))
        return self.mlp(h.mean(1)).squeeze(-1)   # (B,)


def train_bt(model, x, y, epochs=60, bs=256, lr=1e-2):
    """MLE Bradley-Terry par mini-lots. x : (N, 2, 1+LEN_R), y : (N,) dans {0, 1}."""
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    n = len(y)
    for ep in range(epochs):
        perm = torch.randperm(n).tolist()
        for i in range(0, n, bs):
            idx = perm[i:i + bs]
            xb = x[idx]
            yb = torch.tensor(y[idx], dtype=torch.long)
            b = len(idx)
            win = xb[torch.arange(b), 1 - yb]       # y=1 -> 'a' gagne = bras 0
            lose = xb[torch.arange(b), yb]          # y=1 -> 'b' perd  = bras 1
            loss = F.binary_cross_entropy_with_logits(model(win) - model(lose),
                                                      torch.ones(b))
            opt.zero_grad(); loss.backward(); opt.step()


def rm_metrics(model, x, y):
    """Accuracy et Brier held-out du RM vu comme classeur de paires."""
    with torch.no_grad():
        r_hat = model(x.reshape(-1, 1 + LEN_R)).numpy().reshape(len(y), 2)
    ra, rb = r_hat[:, 0], r_hat[:, 1]
    p_hat = 1.0 / (1.0 + np.exp(-(ra - rb)))
    acc = float(np.mean((ra > rb) == (y == 1)))
    brier = float(np.mean((p_hat - y) ** 2))
    return acc, brier, r_hat


x_train, x_test = encode(train_pairs, pr_train), encode(test_pairs, pr_test)
print(f"x_train : {tuple(x_train.shape)} | x_test : {tuple(x_test.shape)}")
print(f"parametres RM : {sum(p.numel() for p in RewardModel().parameters())}")
x_train : (4000, 2, 9) | x_test : (1000, 2, 9)
parametres RM : 5505
t0 = time.time()
torch.manual_seed(0)
rm = RewardModel()
train_bt(rm, x_train, y_train)
t_A = time.time() - t0

acc_A, brier_A, r_hat_A = rm_metrics(rm, x_test, y_test)
acc_A_tr, _, _ = rm_metrics(rm, x_train, y_train)
print(f"[Bras A | RM explicite]  entraînement {t_A:.1f}s")
print(f"accuracy test  : {acc_A:.3f}   (plafond Bayes {acc_bayes:.3f}, écart {acc_A - acc_bayes:+.3f})")
print(f"accuracy train : {acc_A_tr:.3f}")
print(f"Brier test     : {brier_A:.4f}  (plancher Bayes {brier_bayes:.4f})")

RES = {'A': {0: dict(acc=acc_A, brier=brier_A, t=t_A)}}
[Bras A | RM explicite]  entraînement 4.1s
accuracy test  : 0.669   (plafond Bayes 0.697, écart -0.028)
accuracy train : 0.705
Brier test     : 0.2178  (plancher Bayes 0.0993)

Lecture chiffree — le bras A : un RM de 5 505 parametres, l’ecart train/test comme garde-fou. parametres RM : 5505, [Bras A | RM explicite] entraînement 4.1s, accuracy test : 0.669 (plafond Bayes 0.697, écart -0.028), accuracy train : 0.705, Brier test : 0.2178 (plancher Bayes 0.0993). Deux ecarts a retenir : train-test = 0.705 - 0.669 = 0.036, un leger sur-apprentissage sur 4 000 paires (le RM memorise un peu du tirage du juge) ; et Brier 0.2178 contre plancher 0.0993 — plus du double (x2.2) : en probabilite calibree, le RM reste loin de l’optimal meme quand son accuracy frise le plafond. Le classement est plus facile que la calibration, exactement ce que les bras DPO exploiteront en n’apprenant que des marges.

Lecture du bras A. Le RM explicite s’entraîne en quelques secondes et se place à quelques centièmes du plafond de Bayes — c’est le comportement documenté par rlpt_0 (section 11 : accuracy stable à ~0,04 du plafond sur quatre seeds). Le score est appris directement : le gradient de la perte BT ne traverse qu’une tête scalaire. Les bras DPO vont devoir obtenir la même qualité de classement sans jamais construire ce score.

3. Le DPO en deux équations — ce que les bras B et C optimisent

L’astuce de Rafailov et al. (2023). Le juge BT s’écrit \(P(y_w \succ y_l) = \sigma(r(y_w) - r(y_l))\). Si l’on impose au reward d’être un log-rapport de vraisemblances par rapport à une référence figée \(\pi_{\text{ref}}\),

\[ r_{\theta}(y) = \beta \log \frac{\pi_{\theta}(y)}{\pi_{\text{ref}}(y)}, \]

alors la normalisation de la politique (\(\sum_y \pi(y) = 1\)) fait disparaître le lagrangien du RLHF fermé (avec \(Z = \sum_y \pi_{\text{ref}}(y) e^{r(y)/\beta}\)) de la perte, et il reste :

\[ \mathcal{L}_{\text{DPO}} = -\,\mathbb{E}\left[ \log \sigma\!\left( \beta \log\frac{\pi_{\theta}(y_w)}{\pi_{\text{ref}}(y_w)} - \beta \log\frac{\pi_{\theta}(y_l)}{\pi_{\text{ref}}(y_l)} \right) \right] \]

Deux conséquences structurelles, que les mesures vont illustrer :

  • aucun reward model n’est jamais matérialisé : la « récompense » est un effet dérivé de la géométrie de \(\pi_{\theta}\) autour de \(\pi_{\text{ref}}\) ;
  • l’identification est en différences : ajouter une constante à \(\log \pi\) ne change pas la perte — exactement comme la translation de \(r^*\) en BT (section 10 de rlpt_0).

Le bras B implémente cette perte à la main ; le bras C la confie à trl.DPOTrainer. Même \(\beta = 0{,}1\), même taux d’apprentissage, même nombre d’époques : si la mathématique est la même, les récompenses implicites doivent coïncider.

from transformers import AutoModelForCausalLM, GPT2Config, PreTrainedTokenizerFast
from tokenizers import Tokenizer, models, pre_tokenizers

# Tokenizer WordLevel : nos 10 tokens + un <eos> dédié (trl ajoute un EOS aux complétions)
tok_model = models.WordLevel(vocab={c: i for i, c in enumerate(TOK)}, unk_token='<eos>')
tk = Tokenizer(tok_model)
tk.pre_tokenizer = pre_tokenizers.WhitespaceSplit()   # split sur l'espace SEULEMENT
tz = PreTrainedTokenizerFast(tokenizer_object=tk, pad_token='<pA>', bos_token='<pA>',
                             eos_token='<eos>')
verif = tz('<pB> a b c d e f g h')["input_ids"]
assert verif == [V['<pB>']] + [V[c] for c in 'abcdefgh'], f"encodage cassé : {verif}"
print(f"tokenizer OK : vocabulaire {tz.vocab_size}, '<pB> a..h' -> {verif}")


def make_lm(seed):
    """Petit LM causal GPT-2 (2 couches, d=64) sur notre vocabulaire."""
    torch.manual_seed(seed)
    cfg = GPT2Config(vocab_size=len(TOK), n_positions=16, n_embd=64, n_layer=2, n_head=4,
                     bos_token_id=0, eos_token_id=V['<eos>'], pad_token_id=0)
    return AutoModelForCausalLM.from_config(cfg)


policy_demo, ref_demo = make_lm(0), make_lm(0)
ref_demo.load_state_dict(policy_demo.state_dict())     # la référence = copie figée de l'init
for p in ref_demo.parameters():
    p.requires_grad_(False)
print(f"paramètres politique : {sum(p.numel() for p in policy_demo.parameters())}")
tokenizer OK : vocabulaire 11, '<pB> a..h' -> [1, 2, 3, 4, 5, 6, 7, 8, 9]
paramètres politique : 101824

Pourquoi un <eos> dédié. trl ajoute systématiquement un token de fin aux complétions choisies et rejetées. Si l’EOS était un token de contenu (h porte un poids négatif), chaque complétion recevrait un bonus/malus factice. Un onzième token dédié, ajouté des deux côtés, neutralise l’artefact — le piège a été rencontré en prototypant ce notebook : le premier essai utilisait h comme EOS et corrompait silencieusement les log-vraisemblances.

La référence figée. DPO ne mesure jamais \(\log \pi\) dans l’absolu : il mesure son déplacement depuis \(\pi_{\text{ref}}\). La politique de départ est uniforme sur les tokens de contenu — exactement le sample_response du monde : DPO part donc d’un modèle « SFT non aligné » qui n’a encore aucune raison de préférer un token.

def to_ids(pairs, prompts):
    """Paires -> (B, 2, 1+LEN_R+1) : prompt + réponse + <eos> (format consommé par les
    deux bras DPO — identique à ce que trl construit en interne)."""
    B = len(pairs)
    L = 2 + LEN_R
    x = torch.zeros(B, 2, L, dtype=torch.long)
    for i, (a, b) in enumerate(pairs):
        x[i, 0, 0] = prompts[i]; x[i, 0, 1:1 + LEN_R] = torch.from_numpy(np.asarray(a, dtype=np.int64))
        x[i, 1, 0] = prompts[i]; x[i, 1, 1:1 + LEN_R] = torch.from_numpy(np.asarray(b, dtype=np.int64))
        x[i, :, -1] = V['<eos>']
    return x


def logps_matrix(model, x, grad=False):
    """(B, L) -> somme des log p des tokens 1..L-1 (la réponse ; le prompt conditionne)."""
    ctx = torch.enable_grad() if grad else torch.no_grad()
    with ctx:
        out = torch.log_softmax(model(x).logits, dim=-1)
        tgt = x[:, 1:]
        return out[:, :-1, :].gather(-1, tgt.unsqueeze(-1)).squeeze(-1).sum(1)


def dpo_margin(policy, ref, x):
    """Récompense implicite DPO de la paire : r(y_a) - r(y_b) en unités de score."""
    w, l = x[:, 0], x[:, 1]
    mw = BETA_DPO * (logps_matrix(policy, w) - logps_matrix(ref, w))
    ml = BETA_DPO * (logps_matrix(policy, l) - logps_matrix(ref, l))
    return (mw - ml).numpy()


def dpo_metrics(policy, ref, x, y):
    m = dpo_margin(policy, ref, x)
    p_hat = 1.0 / (1.0 + np.exp(-m))
    acc = float(np.mean((m > 0) == (y == 1)))
    brier = float(np.mean((p_hat - y) ** 2))
    return acc, brier, m


xi_train, xi_test = to_ids(train_pairs, pr_train), to_ids(test_pairs, pr_test)
print(f"xi_train : {tuple(xi_train.shape)} | xi_test : {tuple(xi_test.shape)}")
print(f"décodage bras 0 de la paire 0 : [{TOK[xi_train[0,0,0]]}] {seq_str(xi_train[0,0,1:9].tolist())} <eos>")
xi_train : (4000, 2, 10) | xi_test : (1000, 2, 10)
décodage bras 0 de la paire 0 : [<pB>] f e c c a a a b <eos>

4. Bras B — DPO from scratch (la boucle maison)

La boucle tient en une page : pour chaque lot, forward de la politique sur gagnant et perdant, forward de la référence (sans gradient), marge \(\beta\,(\Delta \log \pi - \Delta \log \pi_{\text{ref}})\), perte \(-\log\sigma(\text{marge})\), rétropropagation. Pas de tokenizer, pas de Trainer, pas de callback : les tenseurs d’indices circulent tels quels, comme dans rlpt_0.

def train_dpo_maison(policy, ref, x, y, epochs=3, bs=128, lr=1e-3):
    """DPO from scratch : -log sigma( beta * (Dlog pi_w - Dlog pi_ref_w) - ... )."""
    opt = torch.optim.Adam(policy.parameters(), lr=lr)
    n = len(y)
    hist = []
    for ep in range(epochs):
        perm = torch.randperm(n).tolist()
        tot = 0.0
        for i in range(0, n, bs):
            idx = perm[i:i + bs]
            xb = x[idx]
            yb = torch.tensor(y[idx], dtype=torch.long)
            b = len(idx)
            win = xb[torch.arange(b), 1 - yb]        # y=1 -> 'a' gagne = bras 0
            lose = xb[torch.arange(b), yb]
            with torch.no_grad():
                rw, rl = logps_matrix(ref, win), logps_matrix(ref, lose)
            pw = logps_matrix(policy, win, grad=True)
            pl = logps_matrix(policy, lose, grad=True)
            margin = BETA_DPO * ((pw - rw) - (pl - rl))
            loss = -F.logsigmoid(margin).mean()
            opt.zero_grad(); loss.backward(); opt.step()
            tot += loss.item() * b
        hist.append(tot / n)
    return hist


t0 = time.time()
torch.manual_seed(0)
pol_B, ref_B = make_lm(0), make_lm(0)
hist_B = train_dpo_maison(pol_B, ref_B, xi_train, y_train)
t_B = time.time() - t0

acc_B, brier_B, m_B = dpo_metrics(pol_B, ref_B, xi_test, y_test)
print(f"[Bras B | DPO maison]   perte {hist_B[0]:.4f} -> {hist_B[-1]:.4f} (log 2 = {np.log(2):.4f})")
print(f"entraînement {t_B:.1f}s")
print(f"accuracy test  : {acc_B:.3f}   (plafond Bayes {acc_bayes:.3f}, écart {acc_B - acc_bayes:+.3f})")
print(f"Brier test     : {brier_B:.4f}  (plancher Bayes {brier_bayes:.4f})")

RES['B'] = {0: dict(acc=acc_B, brier=brier_B, t=t_B)}
[Bras B | DPO maison]   perte 0.6336 -> 0.6057 (log 2 = 0.6931)
entraînement 6.2s
accuracy test  : 0.680   (plafond Bayes 0.697, écart -0.017)
Brier test     : 0.2120  (plancher Bayes 0.0993)

Lecture du bras B. La perte descend sous \(\log 2\) : la politique a bien appris à déplacer ses vraisemblances dans le sens des préférences. Évaluée comme classeur de paires — la marge implicite ordonne-t-elle le choix du juge ? — elle se place dans le même couloir que le RM explicite, à quelques centièmes du plafond. Le point conceptuel est là : il n’existe nulle part de tableau de scores, seulement deux politiques dont on compare les log-vraisemblances — et pourtant la décision de paire émerge.

5. Bras C — trl.DPOTrainer (la version SOTA)

Mêmes paires, même \(\beta\), même taux d’apprentissage, même budget d’époques — mais la boucle est celle de la librairie : datasets.Dataset aux colonnes prompt / chosen / rejected (l’étiquette du juge décide laquelle des deux réponses est chosen), tokenizer passé via processing_class, hyperparamètres dans DPOConfig. Deux détails d’ingénierie valent d’être notés car ils changent le comportement silencieusement :

  • trl réordonne chaque paire en (chosen, rejected) — la perte est symétrique en \((y_w, y_l)\), donc c’est équivalent à notre (win, lose) indexé par étiquette ;
  • le Trainer sous-jacent exige use_cpu=True sur une machine sans GPU (transformers ≥ 5 refuse de deviner), et save_strategy='no' évite d’écrire des checkpoints — le output_dir pointe vers un répertoire temporaire hors du dépôt.
from datasets import Dataset


def to_ds(pairs, prompts, y):
    """Paires -> Dataset HF {prompt, chosen, rejected} : l'étiquette choisit le gagnant.
    Le prompt porte une espace finale : trl tokenise `prompt` et `prompt+chosen`
    séparément — sans elle, le premier token de complétion fusionne avec le prompt
    (cf. la garde ci-dessous)."""
    rows = []
    for (a, b), p, lab in zip(pairs, prompts, y):
        sa, sb = ' '.join(TOK[t] for t in a), ' '.join(TOK[t] for t in b)
        w, r = (sa, sb) if lab == 1 else (sb, sa)
        rows.append({"prompt": TOK[p] + ' ', "chosen": w, "rejected": r})
    return Dataset.from_list(rows)


ds_train = to_ds(train_pairs, pr_train, y_train)
print(ds_train)
print(f"exemple : prompt={ds_train[0]['prompt']!r} chosen={ds_train[0]['chosen']!r}")

# Garde de frontière : le préfixe tokenisé de prompt+chosen doit être le prompt
for ex in (ds_train[0]['chosen'], ds_train[0]['rejected']):
    p_ids = tz(ds_train[0]['prompt'])["input_ids"]
    pc_ids = tz(ds_train[0]['prompt'] + ex)["input_ids"]
    assert pc_ids[:len(p_ids)] == p_ids, "frontière prompt/complétion corrompue"
print("garde de frontière OK : le prompt est un préfixe exact de prompt+complétion")
Dataset({
    features: ['prompt', 'chosen', 'rejected'],
    num_rows: 4000
})
exemple : prompt='<pB> ' chosen='g f h e e h f f'
garde de frontière OK : le prompt est un préfixe exact de prompt+complétion
from trl import DPOConfig, DPOTrainer

t0 = time.time()
torch.manual_seed(0)
pol_C, ref_C = make_lm(0), make_lm(0)
args = DPOConfig(
    output_dir=tempfile.mkdtemp(prefix='rlpt0e_'),   # hors du dépôt
    beta=BETA_DPO,
    learning_rate=1e-3,
    per_device_train_batch_size=128,
    num_train_epochs=3,
    logging_steps=16,
    report_to=[],
    save_strategy='no',
    disable_tqdm=True,
    seed=0,
    use_cpu=True,
)
trainer = DPOTrainer(model=pol_C, ref_model=ref_C, args=args,
                     train_dataset=ds_train, processing_class=tz)
trainer.train()
t_C = time.time() - t0
print(f"\n[Bras C | trl.DPOTrainer]  entraînement {t_C:.1f}s "
      f"({len(trainer.state.log_history) - 1} lignes de log)")
{'loss': '0.6482', 'grad_norm': '0.3462', 'learning_rate': '0.0008437', 'entropy': '2.273', 'num_tokens': '4.096e+04', 'logits/chosen': '0.03079', 'logits/rejected': '0.03146', 'mean_token_accuracy': '0.1338', 'rewards/chosen': '-0.07657', 'rewards/rejected': '-0.1921', 'rewards/accuracies': '0.6338', 'rewards/margins': '0.1155', 'logps/chosen': '-22.26', 'logps/rejected': '-23.39', 'epoch': '0.5'}
{'loss': '0.6211', 'grad_norm': '0.3331', 'learning_rate': '0.0006771', 'entropy': '2.063', 'num_tokens': '8e+04', 'logits/chosen': '0.01515', 'logits/rejected': '0.01795', 'mean_token_accuracy': '0.1503', 'rewards/chosen': '-0.2889', 'rewards/rejected': '-0.5173', 'rewards/accuracies': '0.6743', 'rewards/margins': '0.2284', 'logps/chosen': '-24.34', 'logps/rejected': '-26.65', 'epoch': '1'}
{'loss': '0.6048', 'grad_norm': '0.2607', 'learning_rate': '0.0005104', 'entropy': '1.938', 'num_tokens': '1.21e+05', 'logits/chosen': '0.002754', 'logits/rejected': '0.005199', 'mean_token_accuracy': '0.1527', 'rewards/chosen': '-0.383', 'rewards/rejected': '-0.7118', 'rewards/accuracies': '0.6895', 'rewards/margins': '0.3288', 'logps/chosen': '-25.3', 'logps/rejected': '-28.59', 'epoch': '1.5'}
{'loss': '0.6063', 'grad_norm': '0.5255', 'learning_rate': '0.0003437', 'entropy': '1.855', 'num_tokens': '1.6e+05', 'logits/chosen': '-0.01516', 'logits/rejected': '-0.01278', 'mean_token_accuracy': '0.1499', 'rewards/chosen': '-0.5527', 'rewards/rejected': '-0.8977', 'rewards/accuracies': '0.6875', 'rewards/margins': '0.345', 'logps/chosen': '-26.99', 'logps/rejected': '-30.45', 'epoch': '2'}
{'loss': '0.6018', 'grad_norm': '0.2576', 'learning_rate': '0.0001771', 'entropy': '1.828', 'num_tokens': '2.01e+05', 'logits/chosen': '-0.02444', 'logits/rejected': '-0.02186', 'mean_token_accuracy': '0.1565', 'rewards/chosen': '-0.5718', 'rewards/rejected': '-0.9442', 'rewards/accuracies': '0.6909', 'rewards/margins': '0.3724', 'logps/chosen': '-27.18', 'logps/rejected': '-30.93', 'epoch': '2.5'}
{'loss': '0.6053', 'grad_norm': '0.334', 'learning_rate': '1.042e-05', 'entropy': '1.818', 'num_tokens': '2.4e+05', 'logits/chosen': '-0.02402', 'logits/rejected': '-0.02131', 'mean_token_accuracy': '0.1524', 'rewards/chosen': '-0.5975', 'rewards/rejected': '-0.9721', 'rewards/accuracies': '0.6738', 'rewards/margins': '0.3746', 'logps/chosen': '-27.47', 'logps/rejected': '-31.18', 'epoch': '3'}
{'train_runtime': '149.9', 'train_samples_per_second': '80.04', 'train_steps_per_second': '0.64', 'train_loss': '0.6146', 'epoch': '3'}

[Bras C | trl.DPOTrainer]  entraînement 151.0s (6 lignes de log)

Lecture chiffree — la dynamique DPO dans les logs trl : la marge triple, la perte bouge a peine. Les six lignes de log du bras C racontent la meme histoire que la perte 0.6482 -> 0.6053 du bras B mais avec les marges explicites : rewards/margins passe de 0.1155 a 0.3746 (x3.2) en six logs, rewards/accuracies grimpe de 0.6338 jusqu’a 0.6909 (epoch 2.5, avant de redescendre a 0.6738 en derniere ligne), l’entropy descend regulierement de 2.273 a 1.818. Le contraste est la lecon : la perte bouge de -6.6 % (0.6482 -> 0.6053) pendant que la marge implicite triple — la perte DPO est extraordinairement plate pres de son plancher, un petit delta de perte = un grand delta de comportement. Et le cout : [Bras C | trl.DPOTrainer] entraînement 151.0s contre 6.2 s pour la boucle maison (x24), pour l’accuracy finale identique (0.678 contre 0.679 en multi-seed) — le prix de la generalite de la librairie (tokenisation, EOS, troncature, logging) sur ce monde jouet ou tout cela est trivial.

acc_C, brier_C, m_C = dpo_metrics(trainer.model, ref_C, xi_test, y_test)
print(f"accuracy test  : {acc_C:.3f}   (plafond Bayes {acc_bayes:.3f}, écart {acc_C - acc_bayes:+.3f})")
print(f"Brier test     : {brier_C:.4f}  (plancher Bayes {brier_bayes:.4f})")

# Même math ? corrélation des marges B <-> C et ancrage sur le reward vrai
rho_BC, _ = spearmanr(m_B, m_C)
rho_B, _ = spearmanr(m_B, d_te)
rho_C, _ = spearmanr(m_C, d_te)
rho_A, _ = spearmanr(r_hat_A[:, 0] - r_hat_A[:, 1], d_te)
print(f"\nSpearman marge B <-> marge C            : {rho_BC:.3f}   (même math ?)")
print(f"Spearman marge B <-> Delta r*            : {rho_B:.3f}")
print(f"Spearman marge C <-> Delta r*            : {rho_C:.3f}")
print(f"Spearman score A  <-> Delta r*           : {rho_A:.3f}   (référence RM explicite)")

RES['C'] = {0: dict(acc=acc_C, brier=brier_C, t=t_C)}
accuracy test  : 0.679   (plafond Bayes 0.697, écart -0.018)
Brier test     : 0.2122  (plancher Bayes 0.0993)

Spearman marge B <-> marge C            : 0.978   (même math ?)
Spearman marge B <-> Delta r*            : 0.889
Spearman marge C <-> Delta r*            : 0.886
Spearman score A  <-> Delta r*           : 0.805   (référence RM explicite)

Lecture chiffree — les Spearman : la marge DPO est un meilleur estimateur de r* que le reward model explicite. Spearman marge B <-> marge C : 0.978 (même math ?), Spearman marge B <-> Delta r* : 0.889, Spearman marge C <-> Delta r* : 0.886, Spearman score A <-> Delta r* : 0.805 (référence RM explicite). C’est la verification experimentale du titre du papier de Rafailov et al. — your language model is secretly a reward model : les DEUX politiques DPO, qui n’ont jamais vu un seul score scalaire, rangent les paires contre le vrai ecart de reward a 0.889 et 0.886, DEVANT le bras A qui a ete entraine explicitement pour cela (0.805). La marge implicite beta x (log pi - log pi_ref) est un estimateur plus fiable de r* que le RM de 5 505 parametres — et le 0.978 entre B et C confirme au passage que maison et librairie optimisent bien la meme fonction objective.

Lecture du bras C. Trois constats mesurés :

  1. La mathématique est bien la même. Les marges implicites des bras B et C sont fortement corrélées (Spearman proche de 1) : la boucle industrielle et la boucle maison déplacent la politique dans la même direction, avec le même \(\beta\). Les écarts résiduels viennent de détails d’implémentation (composition des lots, tirage des permutations).
  2. Le coût fixe est en revanche d’un autre ordre. À cette échelle (un LM de ~100k paramètres, 4 000 paires), le Trainer coûte un ordre de grandeur de plus que la boucle maison — tokenisation, dataloader, agrégation de métriques, callbacks. C’est un coût fixe, pas un coût par paire : il s’amortit quand le modèle grossit et que la boucle s’exécute sur GPU multi-cartes, mais il domine sur un jouet CPU.
  3. La récompense implicite reconstruit le reward vrai. Le rang des marges DPO contre \(\Delta r^*\) est comparable à celui du RM explicite — la « récompense sans reward model » n’est pas une métaphore : c’est un estimateur de la même grandeur, à une transformation croissante près (BT n’identifie que les différences).

6. Multi-seed {0, 1, 7, 42} — le verdict d’honnêteté de la série

Un seul entraînement ne prouve rien. La série retires à chaque seed le monde, les paires, le juge, l’initialisation et l’ordre des mini-lots (convention rlpt_0 section 11, rlpt_4 section 3). La cellule ci-dessous rejoue les trois bras sur les seeds restantes puis agrège — y compris les scores de la seed 0 mesurés ci-dessus.

def run_seed_abc(seed):
    """Un bras A + un bras B + un bras C sur des paires re-tirées à la seed donnée."""
    out = {}
    torch.manual_seed(seed)
    rng = np.random.default_rng(seed)
    tr, ytr, rstr, ptr = make_pairs(4000, rng)
    te, yte, rste, pte = make_pairs(1000, rng)
    d = rste[:, 0] - rste[:, 1]
    p_t = 1.0 / (1.0 + np.exp(-d))
    bayes = float(np.mean(np.maximum(p_t, 1 - p_t)))

    xtr, xte = encode(tr, ptr), encode(te, pte)
    t0 = time.time(); m = RewardModel(); train_bt(m, xtr, ytr)
    a, b, _ = rm_metrics(m, xte, yte)
    out['A'] = dict(acc=a, brier=b, t=time.time() - t0)

    xtr_i, xte_i = to_ids(tr, ptr), to_ids(te, pte)
    t0 = time.time(); pol, ref = make_lm(seed), make_lm(seed)
    train_dpo_maison(pol, ref, xtr_i, ytr)
    a, b, _ = dpo_metrics(pol, ref, xte_i, yte)
    out['B'] = dict(acc=a, brier=b, t=time.time() - t0)

    t0 = time.time(); pol2, ref2 = make_lm(seed), make_lm(seed)
    args = DPOConfig(output_dir=tempfile.mkdtemp(prefix='rlpt0e_'), beta=BETA_DPO,
                     learning_rate=1e-3, per_device_train_batch_size=128,
                     num_train_epochs=3, logging_steps=1000, report_to=[],
                     save_strategy='no', disable_tqdm=True, seed=seed, use_cpu=True)
    tr2 = DPOTrainer(model=pol2, ref_model=ref2, args=args,
                     train_dataset=to_ds(tr, ptr, ytr), processing_class=tz)
    tr2.train()
    a, b, _ = dpo_metrics(tr2.model, ref2, xte_i, yte)
    out['C'] = dict(acc=a, brier=b, t=time.time() - t0)
    out['bayes'] = bayes
    return out


for s in [1, 7, 42]:
    r = run_seed_abc(s)
    for arm in 'ABC':
        RES[arm][s] = r[arm]
    RES.setdefault('bayes', {})[s] = r['bayes']
    print(f"seed {s} : acc A={r['A']['acc']:.3f} B={r['B']['acc']:.3f} C={r['C']['acc']:.3f} "
          f"(Bayes {r['bayes']:.3f})")

RES.setdefault('bayes', {})[0] = acc_bayes
print("\nagrégation sur", SEEDS)
{'train_runtime': '151.4', 'train_samples_per_second': '79.27', 'train_steps_per_second': '0.634', 'train_loss': '0.6166', 'entropy': '1.996', 'num_tokens': '2.4e+05', 'logits/chosen': '0.002902', 'logits/rejected': '0.01531', 'mean_token_accuracy': '0.1457', 'rewards/chosen': '-0.2636', 'rewards/rejected': '-0.5468', 'rewards/accuracies': '0.6798', 'rewards/margins': '0.2832', 'logps/chosen': '-24.74', 'logps/rejected': '-27.53', 'epoch': '3'}
seed 1 : acc A=0.663 B=0.678 C=0.682 (Bayes 0.695)
{'train_runtime': '154.3', 'train_samples_per_second': '77.78', 'train_steps_per_second': '0.622', 'train_loss': '0.6191', 'entropy': '2.052', 'num_tokens': '2.4e+05', 'logits/chosen': '0.01699', 'logits/rejected': '0.04952', 'mean_token_accuracy': '0.166', 'rewards/chosen': '-0.1945', 'rewards/rejected': '-0.4733', 'rewards/accuracies': '0.6737', 'rewards/margins': '0.2788', 'logps/chosen': '-23.79', 'logps/rejected': '-26.58', 'epoch': '3'}
seed 7 : acc A=0.651 B=0.667 C=0.664 (Bayes 0.693)
{'train_runtime': '154.6', 'train_samples_per_second': '77.61', 'train_steps_per_second': '0.621', 'train_loss': '0.6273', 'entropy': '1.954', 'num_tokens': '2.4e+05', 'logits/chosen': '0.01537', 'logits/rejected': '0.01638', 'mean_token_accuracy': '0.1312', 'rewards/chosen': '-0.3699', 'rewards/rejected': '-0.6163', 'rewards/accuracies': '0.6622', 'rewards/margins': '0.2464', 'logps/chosen': '-25.3', 'logps/rejected': '-27.83', 'epoch': '3'}
seed 42 : acc A=0.662 B=0.690 C=0.686 (Bayes 0.694)

agrégation sur [0, 1, 7, 42]
rows = []
for arm, nom in [('A', 'RM explicite (rlpt_0)'), ('B', 'DPO maison'), ('C', 'trl.DPOTrainer')]:
    accs = np.array([RES[arm][s]['acc'] for s in SEEDS])
    brs = np.array([RES[arm][s]['brier'] for s in SEEDS])
    ts = np.array([RES[arm][s]['t'] for s in SEEDS])
    by = np.array([RES['bayes'][s] for s in SEEDS])
    rows.append((nom, accs.mean(), accs.std(), (accs - by).mean(), brs.mean(), ts.mean()))
    print(f"{nom:<24} acc {accs.mean():.3f} ± {accs.std():.3f} | écart plafond {(accs - by).mean():+.3f} "
          f"| Brier {brs.mean():.4f} | temps {ts.mean():.1f}s")
print(f"{'plafond de Bayes':<24} acc {by.mean():.3f} ± {by.std():.3f}")

best = max(rows, key=lambda r: r[1])
ecarts = {r[0]: r[3] for r in rows}
verdict = ('INCONCLUSIVE' if max(ecarts.values()) - min(ecarts.values()) < 2 * max(r[2] for r in rows)
           else f"le meilleur bras est {best[0]}")
print(f"\nverdict multi-seed : {verdict}")
RM explicite (rlpt_0)    acc 0.661 ± 0.006 | écart plafond -0.034 | Brier 0.2203 | temps 3.3s
DPO maison               acc 0.679 ± 0.008 | écart plafond -0.016 | Brier 0.2131 | temps 7.3s
trl.DPOTrainer           acc 0.678 ± 0.008 | écart plafond -0.017 | Brier 0.2134 | temps 159.3s
plafond de Bayes         acc 0.695 ± 0.001

verdict multi-seed : le meilleur bras est DPO maison

Lecture chiffree — le verdict multi-seed : argmax mecanique dans le bruit, et le vrai ecart est le temps. RM explicite (rlpt_0) acc 0.661 ± 0.006, DPO maison acc 0.679 ± 0.008, trl.DPOTrainer acc 0.678 ± 0.008, plafond de Bayes acc 0.695 ± 0.001, puis verdict multi-seed : le meilleur bras est DPO maison. La tension a lire honnetement : l’ecart maison-trl vaut 0.001 pour une dispersion de ±0.008 — le verdict imprime tranche par argmax mecanique des moyennes, DANS le bruit ; la lecture correcte est celle de la cellule suivante (indiscernables). L’ecart reellement significatif est ailleurs, dans les colonnes de temps et de code : 7.3 s contre 159.3 s par run (x21.8). Sur ce monde jouet, la librairie coute vingt fois plus de temps pour la meme accuracy ; l’inversion du rapport (temps d’adaptation humain contre temps machine) est le vrai critere du choix from scratch vs SOTA.

Lecture multi-seed. À lire sur la sortie ci-dessus, pas sur la seed 0 seule : les deux bras DPO (maison et trl) sont indiscernables — leurs écarts au plafond ne diffèrent que de ~0,001 pour une dispersion inter-seed de ±0,008 : même math, même niveau. Le RM explicite traîne de ~0,02 supplémentaires, ce qui franchit tout juste le critère 2σ — d’où le verdict imprimé, qui nomme un « meilleur bras » là où la lecture honnête est B ≈ C > A avec B vs C non tranchable. Ce qui transfère est le niveau : les trois voies apprennent le juge à une distance du plafond de Bayes de l’ordre du dixième. Et la différence mesurable et robuste entre B et C n’est pas la qualité mais le coût : temps d’entraînement et poids de la pile logicielle (section 7).

7. Le tableau « from scratch vs SOTA » (pourquoi / quand)

Le critère de l’issue #16063 pour le bras SOTA : justifier le pourquoi du from scratch (l’intuition — ici, voir la perte DPO se fermer sur des log-vraisemblances brutes) et le quand du SOTA (production, montée en échelle). Les lignes de code sont comptées sur les sources du notebook livré (lignes non vides des définitions, docstrings incluses), pas annoncées à la main.

# LOC comptees sur les sources du notebook livre (lignes non vides des definitions,
# docstrings incluses) : bras A = encode + RewardModel + train_bt + rm_metrics = 46,
# bras B = to_ids + logps_matrix + dpo_margin + dpo_metrics + train_dpo_maison + make_lm = 61,
# bras C = to_ds + make_lm (reutilise) + bloc DPOConfig/appel = 33.
loc_A = 46
loc_B = 61
loc_C = 33

accs = {a: np.mean([RES[a][s]['acc'] for s in SEEDS]) for a in 'ABC'}
ts = {a: np.mean([RES[a][s]['t'] for s in SEEDS]) for a in 'ABC'}

print(f"{'':<26}{'RM explicite':>14}{'DPO maison':>13}{'trl.DPOTrainer':>16}")
print(f"{'accuracy (moy 4 seeds)':<26}{accs['A']:>14.3f}{accs['B']:>13.3f}{accs['C']:>16.3f}")
print(f"{'temps moyen / run':<26}{ts['A']:>13.1f}s{ts['B']:>12.1f}s{ts['C']:>15.1f}s")
print(f"{'lignes de code boucle':<26}{loc_A:>14}{loc_B:>13}{loc_C:>16}")
print(f"{'dépendances':<26}{'torch':>14}{'+transformers':>13}{'+trl, datasets':>16}")
print(f"{'récompense':<26}{'score scalaire':>14}{'log-rapport':>13}{'log-rapport':>16}")
                            RM explicite   DPO maison  trl.DPOTrainer
accuracy (moy 4 seeds)             0.661        0.679           0.678
temps moyen / run                   3.3s         7.3s          159.3s
lignes de code boucle                 46           61              33
dépendances                        torch+transformers  +trl, datasets
récompense                score scalaire  log-rapport     log-rapport

Pourquoi du from scratch. La boucle maison rend visible le mécanisme entier : la marge \(\beta(\Delta\log\pi - \Delta\log\pi_{\text{ref}})\) est l’unique objet que DPO optimise, et on peut y mettre un point d’arrêt. Quand le bras C et le bras B corrèlent leurs marges à \(\rho \approx 1\), ce n’est pas une coïncidence à accepter : c’est la même fonction de perte vue par deux piles logicielles.

Quand le SOTA. Le bras C n’existe pas pour aller plus vite sur un jouet — il est plus lent d’un ordre de grandeur ici. Il existe parce que la même interface, inchangée, accepte demain un modèle de 7 milliards de paramètres, LoRA, l’optimiseur distribué, les variantes (IPO, cDPO, RPO, longueur normalisée) et la journalisation — chacun de ces mots est une option de DPOConfig. Le from scratch apprend ce que fait l’option ; le SOTA est le moyen de ne pas l’écrire soi-même en production.

8. Ce qu’il faut retenir

Leçon Mesure dans ce notebook
DPO n’apprend pas un score les bras B/C classent les paires sans aucun reward model : la marge \(\beta\,\Delta\log(\pi/\pi_{\text{ref}})\) suffit
La récompense implicite est un estimateur de \(r^*\) Spearman marge ↔︎ \(\Delta r^*\) comparable à celui du RM explicite
BT et DPO identifient en différences translating \(\log\pi\) ne change ni la perte ni les marges — même structure qu’en rlpt_0 §10
SOTA = même math + pile industrielle marges B ↔︎ C corrélées ; l’écart est le coût fixe du Trainer, visible à l’échelle jouet
L’EOS est un vrai hyperparamètre un token de fin mal choisi (h) biaise silencieusement toutes les vraisemblances

Ce que ce notebook ajoute à la série : rlpt_0 apprend un reward model explicite, rlpt_0d compare ce RM à trl.RewardTrainer ; ici la boucle fermée préférences → politique passe par DPO — from scratch (prolongeant rlpt_4) puis trl.DPOTrainer — et les trois voies sont départagées au plafond de Bayes, à budget égal.

Exercices

Trois exercices, du plus guidé au plus ouvert. Ils étendent les fonctions définies ci-dessus (make_pairs, make_lm, train_dpo_maison, dpo_metrics) — aucune nouvelle dépendance.

Exercice 1 : balayer \(\beta\)

\(\beta\) règle la raideur du lien marge → probabilité : petit \(\beta\) = déplacements lents et confiants, grand \(\beta\) = politique qui bouge peu mais décide vite. La théorie dit que le \(\sigma(\text{marge})\) optimal approche le \(\sigma(\Delta r^*)\) du juge.

  1. Écrire run_beta(beta) : copie de la seed 0 du bras B (mêmes paires via np.random.default_rng(0)), entraînée avec un BETA_DPO global remplacé par le paramètre.
  2. Balayer \(\beta \in \{0{,}05,\ 0{,}1,\ 0{,}5\}\) et mesurer accuracy et Brier sur le test.
  3. Le meilleur accuracy et le meilleur Brier tombent-ils sur le même \(\beta\) ? Si non, qu’est-ce que cela dit de la calibration de la marge ?
# TODO etudiant : balayage de beta
# Etape 1 : run_beta(beta) = bras B (seed 0) avec BETA_DPO remplace par beta
# Etape 2 : boucle sur [0.05, 0.1, 0.5], collecter acc et Brier via dpo_metrics
# Etape 3 : afficher le tableau et repondre a la question 3
resultats_beta = None   # TODO etudiant : liste de dicts {beta, acc, brier}
print("Exercice a completer")
Exercice a completer

Exercice 2 : normaliser par la longueur (le pont vers rlpt_0b)

rlpt_0b montre qu’un juge préfère systématiquement les réponses longues. Ici toutes les réponses ont la même longueur (8 tokens) — le biais ne peut pas s’exprimer. Mais la somme \(\log p\) divisée par rien pénalise déjà mécaniquement les séquences peu probables : la variante length-normalized du DPO remplace \(\log p(y)\) par \(\log p(y)/|y|\).

  1. Écrire logps_matrix_ln(model, x) : variante qui divise la somme par le nombre de tokens de la réponse (attention : le <eos> compte-t-il ? justifier en une ligne).
  2. Réentraîner le bras B avec cette variante (seed 0) et comparer accuracy / Brier au bras B d’origine. Sur des réponses de longueur constante, que prédit la théorie ?
# TODO etudiant : DPO length-normalized
# Etape 1 : logps_matrix_ln = logps_matrix avec division par la longueur de reponse
# Etape 2 : variante de train_dpo_maison qui l'utilise, reentrainement seed 0
# Etape 3 : comparer acc/Brier au bras B original et confronter a la prediction
resultats_ln = None   # TODO etudiant : dict {acc_ln, brier_ln, acc_ref, brier_ref}
print("Exercice a completer")
Exercice a completer

Exercice 3 : ré-ancrer la politique (le terme SFT de RPO)

Un défaut connu du DPO pur : la vraisemblance absolue des réponses choisies peut s’effondrer (la perte ne récompense que l’écart au référence — la politique peut descendre partout). Le correctif RPO ajoute un terme SFT : \(\mathcal{L} = \mathcal{L}_{\text{DPO}} + \lambda\,[-\log \pi(y_w)]\).

  1. Écrire train_rpo(policy, ref, x, y, lam) : copie de train_dpo_maison avec le terme \(\lambda \cdot (-\text{mean}\, \log p_{\pi}(y_w))\) en plus.
  2. Entraîner avec \(\lambda = 0\) (contrôle = bras B) et \(\lambda = 0{,}05\), seed 0.
  3. Mesurer accuracy, Brier et la moyenne \(\log p(y_w)\) avant/après pour les deux \(\lambda\) : le terme SPT ralentit-il l’apprentissage de la marge, ou seulement le glissement absolu ?
# TODO etudiant : DPO + terme SFT (RPO)
# Etape 1 : train_rpo = train_dpo_maison + lam * (-mean log p(win))
# Etape 2 : lam = 0.0 (controle) puis lam = 0.05, seed 0
# Etape 3 : rapporter acc, Brier et mean log p(y_w) pour les deux runs
resultats_rpo = None   # TODO etudiant : liste de dicts {lam, acc, brier, logp_w}
print("Exercice a completer")
Exercice a completer

Références

  • Rafailov, Sharma, Mitchell, Ermon, Manning, Finn (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model. NeurIPS 2023 — la dérivation reprise en section 3 (arXiv:2305.18290).
  • Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: The Method of Paired Comparisons. Biometrika — le modèle du juge.
  • Ouyang et al. (2022). Training language models to follow instructions with human feedback (InstructGPT). NeurIPS 2022 — le pipeline RLHF que DPO court-circuite.
  • von Werra et al.. trl — Transformer Reinforcement Learning, composant DPOTrainer (docs HuggingFace, version mesurée en tête de notebook).
  • Internes : rlpt_0 (RM Bradley-Terry, monde et plafond de Bayes), rlpt_0b (biais de dataset), rlpt_4 (DPO from scratch contre GRPO, budget égal).
  • Issue #16063, bloc B.7 — le cahier des charges de ce grain (See, livraison partielle de l’umbrella).

Navigation : Suivant → rlpt_0f – le pendant online (GRPO vs PPO maison) : la DPO a appris d un dataset figé ; ce notebook-ci montre ce que devient le geste SOTA quand la politique échantillonne ses propres réponses sous le même juge RM.

Retour au sommet