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 :
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 ;
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 ;
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 jsonfrom pathlib import Pathimport numpy as npimport 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).
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 statsDM_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)
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 sigmaif 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 moinsif 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": Noneif p_med isNoneelseround(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")