PT-18 — laya étage 2 : l’ablation à trois bras — ce que le « RL calibré » doit à la cross-entropie

↑ PostTraining — étage 2 du chantier laya

PT-17 (étage 1, CPU) a posé sur une tâche jouet le contraste récompense propre vs impropre, établi la famille des récompenses propres et mesuré l’estimateur par perturbation contre le gradient direct. Ce carnet monte à l’échelle : il entraîne le vrai checkpoint convaiinnovations/laya-multilingual (322 M de paramètres, encodeur mmBERT-base) sur LocalLLaMA/typed-decisions, et décompose la recette publiée pour répondre à la question laissée ouverte :

quelle part de l’exactitude et de la calibration revient au terme RL, et quelle part au terme de cross-entropie ?

Rappel des trois lectures de PT-17 qui motivent l’expérience :

  1. le score logarithmique est lui-même une règle strictement propre — la surconfiance des réseaux modernes vient de l’ajustement de la NLL sur des données finies (Guo et al., 2017), pas d’une impropreté de la perte ;
  2. quand la cible est observée et la récompense différentiable, le gradient direct est disponible, et l’estimateur par perturbation n’en estime qu’une version lissée, avec plus de variance ;
  3. le billet et le code publié ne décrivent pas le même entraînement — le code entraîne loss_rl + 1.0 * loss_ce (issue #17878).

1. Le protocole — trois bras, quatre graines, un jeu tenu à part

La recette amont (laya_finetune_typed_decisions_2xT4_kaggle.ipynb, dépôt NandhaKishorM/laya) entraîne sur typed-decisions avec un groupe GRPO de 4, un bruit d’exploration décroissant de 0,4 à 0,1, un poids sphérique de 0,75 et une cross-entropie douce à plein poids. Les bras ne diffèrent que par la composition de la perte :

Bras Perte Ce qu’il isole
ce loss_ce seule l’ajustement direct de la NLL sur les cibles douces
rl loss_rl seule le gradient de politique par perturbation, récompense propre
rl_ce loss_rl + 1.0 * loss_ce la recette publiée
  • Données : split train (6 000 décisions, 1 200 cas) pour l’entraînement ; un slice de calibration de 400 décisions est retenu avant le sharding (germe amont 20260922) pour l’ajustement des températures ; l’évaluation porte sur le split test (2 000 décisions, 400 cas), jamais vu.
  • Graines : 0, 1, 7, 42 — même ordre d’items d’un run à l’autre (l’ordre est indépendant de la graine, comme amont) ; la graine ne pilote que le bruit d’exploration.
  • Budget par run : 4 époques, micro-batch 8, accumulation 4, LR encodeur 2,5e-5 / tête 1e-4, cosine, fp16 ; 66 à 108 min par run sur RTX 4060 (médiane ~72 min ; un run ce sous contention nocturne a pris 279 min, compté comme les autres).
  • Mesures (split test) : exactitude, NLL, Brier, ECE avant et après température, courbe couverture-exactitude de la décision sélective.

Les instruments : _measurements/pt18_prep.py (construction des items), _measurements/pt18_train.py (entraînement 3 bras + métriques) et _measurements/pt18_eval.py (évaluation + pertes par décision). Les checkpoints (614 Mo safetensors par run) et les predictions par décision (~230 Ko par run) restent hors dépôt ; le carnet lit les agrégats committés pt18_per_run_metrics.jsonl et pt18_dm_paired.jsonl.

import json
from pathlib import Path

import numpy as np
import pandas as pd

# Jeu committé : une ligne par run (3 bras x 4 graines), métriques pooled par phase.
# Produit par _measurements/pt18_train.py + pt18_eval.py ; les runs complets
# (checkpoints 614 Mo, predictions par décision) vivent hors dépôt.
RUNS_JSONL = Path("MyIA.AI.Notebooks/GenAI/PostTraining/_measurements/pt18_per_run_metrics.jsonl")
ARMS = ["ce", "rl", "rl_ce"]
SEEDS = [0, 1, 7, 42]

rows = []
for line in RUNS_JSONL.read_text(encoding="utf-8").splitlines():
    m = json.loads(line)
    for phase in ["before_T1", "after_T1", "after_Tfitted"]:
        p = m[phase]
        rows.append({
            "arm": m["arm"], "seed": m["seed"], "phase": phase,
            "acc": p["acc"], "nll": p["nll"], "brier": p["brier"], "ece": p["ece"],
            "cov@0.9": p["coverage_accuracy"]["cov0.9"],
        })
df = pd.DataFrame(rows)
print(f"{len(df)} mesures chargees ({len(ARMS)} bras x {len(SEEDS)} graines = {len(ARMS)*len(SEEDS)} runs)")
df.head(12)
36 mesures chargees (3 bras x 4 graines = 12 runs)
arm seed phase acc nll brier ece cov@0.9
0 ce 0 before_T1 0.3465 1.857000 0.462636 0.320417 0.349444
1 ce 0 after_T1 0.7690 0.881581 0.061797 0.136915 0.796111
2 ce 0 after_Tfitted 0.7690 0.879541 0.060963 0.146687 0.796111
3 ce 1 before_T1 0.3465 1.857000 0.462636 0.320417 0.349444
4 ce 1 after_T1 0.7545 0.875950 0.060574 0.124119 0.785556
5 ce 1 after_Tfitted 0.7545 0.873640 0.059482 0.138257 0.786111
6 ce 7 before_T1 0.3465 1.857000 0.462636 0.320417 0.349444
7 ce 7 after_T1 0.7590 0.880559 0.061574 0.125773 0.793889
8 ce 7 after_Tfitted 0.7590 0.878163 0.060606 0.136295 0.792222
9 ce 42 before_T1 0.3465 1.857000 0.462636 0.320417 0.349444
10 ce 42 after_T1 0.7660 0.870471 0.056321 0.132568 0.800000
11 ce 42 after_Tfitted 0.7660 0.868954 0.055690 0.140560 0.800000

2. Lecture du tableau — moyenne et dispersion inter-graines

Chaque cellule du tableau ci-dessous agrège les quatre graines : moyenne ± écart-type. L’écart-type inter-graines est la dispersion que le verdict §C exige (écart ≥ 2σ entre bras).

pivot = df[df.phase == "after_T1"].pivot_table(index="arm", values=["acc", "nll", "brier", "ece"], aggfunc=["mean", "std"])
pivot.round(4)
mean std
acc brier ece nll acc brier ece nll
arm
ce 0.7621 0.0601 0.1298 0.8771 0.0066 0.0026 0.0060 0.0051
rl 0.7570 0.0607 0.1253 0.8781 0.0027 0.0015 0.0031 0.0029
rl_ce 0.7566 0.0596 0.1244 0.8764 0.0018 0.0016 0.0029 0.0022

3. Significativité — écart 2σ et test de Diebold-Mariano

Le verdict suit le §C de la discipline de review du dépôt : une différence ne compte que si elle tient à la fois l’écart ≥ 2σ cross-seed et le test de Diebold-Mariano sur une perte de précision (MSE des probabilités rapportées vs cibles douces), apparié par décision de test.

# Test de Diebold-Mariano apparié par décision de test, perte = MSE des distributions rapportées.
# Les pertes par décision viennent des checkpoints (hors dépôt) rechargés par
# _measurements/pt18_eval.py, qui écrit predictions.jsonl à côté de chaque metrics.json
# (mêmes items, même ordre -> l'index d'item apparie deux bras graine par graine).
# Signe : d_t = L_a(t) - L_b(t) ; dm < 0 signifie que le bras a perd MOINS que b.
# Le tableau ci-dessous est l'agrégat committé (statistique DM + p-value bilatérale
# Student(T-1), calculées depuis les pertes appariées) ; l'exercice final le recode.
from scipy import stats

DM_JSONL = Path("MyIA.AI.Notebooks/GenAI/PostTraining/_measurements/pt18_dm_paired.jsonl")
PAIRS = [("rl_ce", "ce"), ("rl_ce", "rl"), ("rl", "ce")]

dm_df = pd.DataFrame([json.loads(l) for l in DM_JSONL.read_text(encoding="utf-8").splitlines()])
dm_summary = dm_df.groupby("paire").agg(p_median=("p", "median"), dm_median=("dm", "median"))
dm_df, dm_summary.round(4)
(         paire  seed      dm       p
 0   rl_ce - ce     0 -2.5264  0.0116
 1   rl_ce - ce     1  0.4240  0.6716
 2   rl_ce - ce     7 -1.1536  0.2488
 3   rl_ce - ce    42  3.0720  0.0022
 4   rl_ce - rl     0 -4.1275  0.0000
 5   rl_ce - rl     1  1.8461  0.0650
 6   rl_ce - rl     7 -2.6620  0.0078
 7   rl_ce - rl    42  0.1131  0.9099
 8      rl - ce     0 -0.1545  0.8772
 9      rl - ce     1 -1.2285  0.2194
 10     rl - ce     7  0.7492  0.4538
 11     rl - ce    42  2.8921  0.0039,
             p_median  dm_median
 paire                          
 rl - ce       0.3366     0.2974
 rl_ce - ce    0.1302    -0.3648
 rl_ce - rl    0.0364    -1.2744)

4. Calibration — avant et après température

Les températures sont ajustées par type de question sur le slice de calibration retenu à l’entraînement (LBFGS sur log T, borne [0,1 ; 10]), jamais sur le test. La question de fond : le bras rl produit-il une calibration avant température que ce n’obtient qu’après ajustement ?

cal = df[df.phase.isin(["after_T1", "after_Tfitted"])].pivot_table(
    index=["arm", "phase"], values=["ece", "nll", "brier"], aggfunc="mean"
)
cal.round(4)
brier ece nll
arm phase
ce after_T1 0.0601 0.1298 0.8771
after_Tfitted 0.0592 0.1404 0.8751
rl after_T1 0.0607 0.1253 0.8781
after_Tfitted 0.0600 0.1349 0.8765
rl_ce after_T1 0.0596 0.1244 0.8764
after_Tfitted 0.0587 0.1362 0.8745

5. Décision sélective — courbe couverture-exactitude

La promesse opérationnelle d’un modèle de décision calibré : pouvoir refuser les cas incertains. La courbe couverture-exactitude trie les décisions par confiance et lit l’exactitude des fractions les plus confiantes. Un modèle calibré y gagne plus tôt et plus haut.

La courbe trie par confiance top-1 — la probabilité que le modèle rapporte pour la décision qu’il prend, seule quantité disponible à l’inférence. Trier par la masse de la classe vraie serait une lecture d’oracle : elle placerait les erreurs en dernier par construction et la courbe serait parfaite sans rien mesurer.

cov_rows = []
for line in RUNS_JSONL.read_text(encoding="utf-8").splitlines():
    m = json.loads(line)
    for phase in ["after_T1", "after_Tfitted"]:
        curve = m[phase]["coverage_accuracy"]
        for cov, acc in curve.items():
            cov_rows.append({"arm": m["arm"], "seed": m["seed"], "phase": phase, "coverage": cov, "acc": acc})
cov_df = pd.DataFrame(cov_rows)
cov_df[cov_df.phase == "after_T1"].pivot_table(index="coverage", columns="arm", values="acc", aggfunc="mean").round(3)
arm ce rl rl_ce
coverage
cov0.5 0.904 0.900 0.906
cov0.6 0.879 0.873 0.874
cov0.7 0.856 0.845 0.843
cov0.8 0.828 0.817 0.819
cov0.9 0.794 0.786 0.791
cov0.95 0.781 0.772 0.773
cov0.99 0.767 0.762 0.761

6. Verdict

Conjonction du §C — écart ≥ 2σ inter-graines sur la NLL et DM apparié p_median < 0,05 dans le même sens — sur 4 graines par bras. Les deux jambes lisent le même état du modèle (après température ajustée), et la perte de la jambe DM est mesurée sur les mêmes checkpoints que l’agrégat :

Comparaison ΔNLL (a−b) 2σ jambe 2σ DM p_median verdict
rl_ce vs ce −0,0005 0,0096 non 0,1302 NO BEATS
rl_ce vs rl −0,0019 0,0066 non 0,0364 INCONCLUSIVE (jambe DM seule)
rl vs ce +0,0014 0,0096 non 0,3366 NO BEATS

Réponse au titre : rien de mesurable — dans ce protocole et ce budget, le terme RL n’apporte aucun gain démontré au-delà de la cross-entropie. Les trois bras atterrissent à NLL 0,875–0,876 (σ inter-graines 0,002–0,005) et exactitude 0,754–0,769. La seule jambe qui passe — DM rl_ce vs rl, p = 0,0364 — indique que la recette publiée fait mieux que le RL seul sur la perte de précision appariée ; mais son écart de NLL reste ≈ 3× sous le seuil 2σ, donc cette paire est non conclusive, pas un gain établi. Le bras rl seul ne bat jamais ce.

Ce que ce résultat ne dit pas, et que la formulation précédente laissait croire :

  • ce n’est pas une équivalence : l’absence de gain démontré n’est pas une preuve d’égalité — le protocole n’a pas la puissance de borner l’écart par le haut (4 graines, σ inter-graines de l’ordre de l’effet cherché) ;
  • ce n’est pas une attribution exclusive : rien ici n’établit que l’exactitude de la recette publiée « revienne » à sa jambe de cross-entropie plutôt qu’à une interaction entre les deux termes.

La question laissée ouverte par PT-17 reste donc ouverte. Ce carnet la documente avec quatre graines par bras et un jeu de test tenu à part ; il ne la clôt pas.

# Verdict SC : une paire ne compte que si elle tient A LA FOIS (i) l'ecart >= 2 sigma cross-seed
# sur la NLL moyenne et (ii) DM p_median < 0.05 dans le meme sens. Verdicts possibles :
# "<a> BEATS <b>" / "NO BEATS" / "INCONCLUSIVE" (jambes divergentes).
# Les deux jambes lisent le MEME etat du modele : la perte appariee du DM (pt18_eval.py)
# est mesuree aux temperatures ajustees, donc la jambe 2 sigma lit `after_Tfitted` et non
# `after_T1` -- melanger les deux etats reviendrait a comparer deux modeles differents.
piv_nll = df[df.phase == "after_Tfitted"].pivot_table(index="arm", values="nll", aggfunc=["mean", "std"])
verdict_rows = []
for a, b in PAIRS:
    paire = f"{a} - {b}"
    ma, mb = piv_nll.loc[a, ("mean", "nll")], piv_nll.loc[b, ("mean", "nll")]
    sa, sb = piv_nll.loc[a, ("std", "nll")], piv_nll.loc[b, ("std", "nll")]
    diff = ma - mb                      # < 0 : le bras a a une NLL plus basse (meilleure)
    sigma = max(sa, sb)
    leg_sigma = diff <= -2 * sigma      # a meilleur d'au moins 2 sigma
    if paire in dm_summary.index:
        p_med = dm_summary.loc[paire, "p_median"]
        dm_med = dm_summary.loc[paire, "dm_median"]
        leg_dm = (p_med < 0.05) and (dm_med < 0)   # a perd significativement moins
        if leg_sigma and leg_dm:
            verdict = f"{a} BEATS {b}"
        elif (not leg_sigma) and (not leg_dm):
            verdict = "NO BEATS"
        else:
            verdict = "INCONCLUSIVE"
    else:
        p_med, dm_med, leg_dm, verdict = None, None, None, "INCONCLUSIVE"
    verdict_rows.append({"paire": paire, "dnll(a-b)": round(diff, 4), "2sigma": round(2 * sigma, 4),
                         "jambe_2sigma": leg_sigma, "p_median_DM": None if p_med is None else round(p_med, 4),
                         "jambe_DM": leg_dm, "verdict": verdict})
verdict_table = pd.DataFrame(verdict_rows)
print(verdict_table.to_string(index=False))
verdict_table
     paire  dnll(a-b)  2sigma  jambe_2sigma  p_median_DM  jambe_DM      verdict
rl_ce - ce    -0.0005  0.0096         False       0.1302     False     NO BEATS
rl_ce - rl    -0.0019  0.0066         False       0.0364      True INCONCLUSIVE
   rl - ce     0.0014  0.0096         False       0.3366     False     NO BEATS
paire dnll(a-b) 2sigma jambe_2sigma p_median_DM jambe_DM verdict
0 rl_ce - ce -0.0005 0.0096 False 0.1302 False NO BEATS
1 rl_ce - rl -0.0019 0.0066 False 0.0364 True INCONCLUSIVE
2 rl - ce 0.0014 0.0096 False 0.3366 False NO BEATS

Exercice — implémenter le test de Diebold-Mariano

La section 3 affiche l’agrégat committé du test ; recoder le calcul lui-même. Les pertes appariées par décision d’une paire réelle (rl_ce graine 0 vs ce graine 0, split test) sont committées dans _measurements/pt18_dm_exercise_losses.jsonl — le résultat à retrouver est la ligne rl_ce - ce, graine 0, du tableau dm_df.

# Indice 1 : la difference de perte par decision d = L_a(i) - L_b(i) est la serie a tester.
# Indice 2 : DM = mean(d) / sqrt(var(d) / T) ; sous H0, DM suit approx. une Student(T-1).
# Indice 3 : appeler les decisions, pas les cas -- l'appariement est par decision.
# Etape 1 : charger les pertes par decision depuis _measurements/pt18_dm_exercise_losses.jsonl
#           (colonnes loss_rl_ce et loss_ce, une ligne par decision de test).
# Etape 2 : construire d, calculer DM et sa p-value bilaterale, comparer a dm_df.
print("Exercice a completer")
Exercice a completer
Retour au sommet