PT-17 — laya : la règle de score propre comme récompense — ce que le RL gagne quand le terme de cross-entropie est retiré
Position dans la série
PT-08 a posé GRPO, PT-09 RLOO, PT-10 GAE : les estimateurs. PT-04 / PT-05 / PT-16 ont posé la question de la récompense. PT-17 ferme la case manquante : les récompenses qui notent une probabilité plutôt qu’une réponse — la famille à laquelle appartient la règle de score propre utilisée par laya (https://github.com/NandhaKishorM/laya, Apache-2.0).
L’enjeu est double :
Comparaison empirique entre récompenses impropres (binaire ; linéaire Σ y_k q_k) et récompenses propres (log-scorer, Brier, sphérique). Mesure de la calibration (ECE, Brier, diagramme de fiabilité).
Comparaison d’estimateurs à récompense propre fixée : perturbation gaussienne des logits (style GRPO, groupe G, avantage relatif au groupe) contre gradient direct quand la cible y est observée.
Étage 2 — ablation sur laya-multilingual sur GPU 24 Go — est laissé à une PR séparée.
Question que ce notebook tranche
Laya combine dans sa récompense log + λ · sphérique − RPS (RPS pour les seules questions ordinales, laya/common.py::proper_reward). Le billet de l’auteur affirme que la cross-entropie rend surconfiant et que la récompense propre rend calibré. Or le score logarithmique est lui-même strictement propre, et la cross-entropie en est l’opposé : 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.
Sur une tâche jouet où la vraie distribution p(y|x) est connue (Bernoulli paramétrée), on peut trancher :
la récompense impropre (binaire +1/0) pousse à un pic de Dirac (sur-confiance extrême) ;
la récompense propre (log) garde la distribution rapportée proche de p ;
la Brier rapproche de p en distance euclidienne quadratique ;
la sphérique est invariante au support et calibre par géométrie.
Côté estimateur, à récompense propre fixée : la perturbation gaussienne des logits (GRPO-like, G=8) et le gradient direct diffèrent par variance surtout. La perturbation surestime le gradient d’une version lissée du même objectif — c’est l’écart mesuré.
Contre-lectures à mesurer, pas à recopier
Zéro-shot 65,1 % : porte sur un checkpoint fine-tuné testé sur des familles non vues ; on n’y touche pas ici.
« RL calibré » : la confiance rendue vaut 1 − H(p)/log K (entropie normalisée), pas une probabilité d’être juste. La calibration se mesure sur p, pas sur confiance.
« Ne peut pas halluciner » : c’est vrai par construction de toute tête de classification ; l’erreur de décision (mauvaise option rendue avec une haute probabilité) reste possible et c’est ce que ce notebook mesure.
Cardinalité bornée : au-delà de 255 options, laya passe à un schéma en deux étapes — hors périmètre ici (notre tâche jouet est à 2 puis 4 options).
Bibliographie (archivée sur GDrive, chemins cités)
Gneiting & Raftery (2007), Strictly Proper Scoring Rules, Prediction, and Estimation, JASA 102(477). Guo, Pleiss, Sun & Weinberger (2017), On Calibration of Modern Neural Networks, arXiv:1706.04599. Nandakishor M., arXiv:2503.23303 et arXiv:2510.01237. Billet dev.to, capture PDF datée.
Critère de sortie
Notebook exécuté de bout en bout, sorties committées, 0 erreur.
Tableau des récompenses × calibration (ECE, Brier, fiabilité).
Tableau estimateurs (perturbation vs gradient direct) × biais / variance / vitesse.
Verdict explicite : « la cross-entropie rend surconfiant » est une hypothèse de mécanisme, pas un fait empirique — la sur-confiance vient des données, pas de la perte.
1. Tâche jouet et vraie distribution
On construit une tâche de classification binaire puis catégorielle 4-classes où l’on connaît la vraie distribution p(y|x) :
Modèle : perceptron à une couche, sortie q_θ(y|x) = softmax(W x + b) (paramétré pour sur-confiance initiale).
Pourquoi connaître p : sans vérité terrain, on ne peut pas mesurer l’ECE ni la Brier de manière non circulaire. Le but du notebook est de mesurer comment l’optimiseur pousse q vers p selon la récompense — pas de prédire sur des données réelles.
# Imports et reproductibiliteimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Fimport matplotlib.pyplot as pltfrom dataclasses import dataclassSEED =42torch.manual_seed(SEED)np.random.seed(SEED)DEVICE = torch.device('cpu')# --- Tache binaire : p(y=1|x) = sigmoid(x . theta_star) ---D =8# dimension des featurestorch.manual_seed(SEED)theta_star = torch.randn(D) # vraie directionBIAS_STAR =0.0def true_p_binary(x):return torch.sigmoid(x @ theta_star + BIAS_STAR)def sample_binary(x, rng): p = true_p_binary(x).numpy()return torch.from_numpy(rng.binomial(1, p).astype(np.int64))def make_batch(n, rng): x = torch.randn(n, D) y = sample_binary(x, rng)return x.to(DEVICE), y.to(DEVICE)# --- Tache 4-classes (utilisee pour le cas spherique et le Brier multi-classe) ---K4 =4W_star = torch.randn(K4, D) *0.7def true_p_categorical(x):return torch.softmax(x @ W_star.T, dim=-1)def sample_categorical(x, rng): p = true_p_categorical(x).numpy() out = np.empty(len(x), dtype=np.int64)for i, pi inenumerate(p): out[i] = rng.choice(K4, p=pi)return torch.from_numpy(out)def make_batch_cat(n, rng): x = torch.randn(n, D) y = sample_categorical(x, rng)return x.to(DEVICE), y.to(DEVICE)print(f'torch {torch.__version__} | device={DEVICE} | seed={SEED}')print(f'Tache binaire : d={D}, theta_star[:3]={theta_star[:3].tolist()}')print(f'Tache 4-classes : K={K4}, W_star shape={tuple(W_star.shape)}')
On importe les fonctions de 2.5b-Calibration-Probabilites.ipynb (cellule 7), étendues au multi-classes :
ece_top_label(y_true, q_proba) : somme pondérée par bin des écarts |confiance − fréquence|, sur la confiance top-label (argmax). C’est la définition standard.
brier_multiclass(y_true, q_proba) : moyenne empirique de Σ_k (y_k − q_k)², sans division par K. C’est la convention scikit-learn (log_loss / brier_score_loss) et Murphy §8.1 : la borne uniforme vaut (K−1)/K. La convention normalisée par K divise en plus par K et borne à (K−1)/K² ; elle est distincte — l’écart ~K entre les deux n’est pas un résultat, c’est un changement de convention.
Référence : ECE et Brier du hasard sont deux bornes distinctes selon la convention — (K−1)/K (non normalisée, notre brier_multiclass) et (K−1)/K² (normalisée). Les deux sont affichées dans la cellule code suivante.
# Bornes uniformes du Brier multiclasses -- cellule distincte #17894 acceptance# Convention `brier_multiclass` : somme sans diviser par K (NOTRE convention)print(f"Borne uniforme non normalisee = (K-1)/K = (4-1)/4 = 0.7500 (convention brier_multiclass, NOTRE convention)")print(f"Borne uniforme non normalisee pour K=4 : 3/4 = 0.7500 (theorique, classifieur constant q=1/K)")
Borne uniforme non normalisee = (K-1)/K = (4-1)/4 = 0.7500 (convention brier_multiclass, NOTRE convention)
Borne uniforme non normalisee pour K=4 : 3/4 = 0.7500 (theorique, classifieur constant q=1/K)
# Borne uniforme normalisee par K -- cellule distincte (convention differente)# Convention normalisee : moyenne par K en plus -> divise par K -> borne = (K-1)/K^2print(f"Borne uniforme normalisee par K = (K-1)/K^2 = (4-1)/16 = 0.1875 (convention normalisee, distincte de brier_multiclass)")
Borne uniforme normalisee par K = (K-1)/K^2 = (4-1)/16 = 0.1875 (convention normalisee, distincte de brier_multiclass)
Qualification de l’ecart entre les deux bornes (issue #17894 acceptance) :
Pour K=4, le rapport entre les deux conventions est exactement K = 4 : (K-1)/K / ((K-1)/K^2) = K. L’ecart n’est pas un resultat empirique ni une degradation, c’est un changement de convention :
convention non normalisee (brier_multiclass, NOTRE) : somme sur K classes sans diviser par K -> echelle ~O(1) -> borne (K-1)/K = 0.75 ;
convention normalisee par K : divise en plus par K -> echelle ~O(1/K) -> borne (K-1)/K^2 = 0.1875 ;
scikit-learn (log_loss, brier_score_loss) et Murphy §8.1 utilisent la convention non normalisee. Le Brier empirique de notre tirage aleatoire Dirichlet(1, K=4) est ~0.91, superieur a la borne du cas constant (q=1/K) de 0.75 – c’est attendu car un tirage aleatoire a plus de variance qu’un classifieur constant ; cela ne contredit pas la borne, cela dit que la distribution aleatoire est “plus mauvaise” que le classifieur constant le plus prudent.
Note historique : la numerotation de l’acceptance utilisait “~5x” comme arrondi verbal ; pour K=4 le rapport est exactement x4. La convention reste la meme, c’est l’imprecision qui etait dans la redaction originale de l’issue #17894.
4. Entraînement par récompense — calibration finale
On entraîne un modèle différent par récompense sur la même tâche binaire, même graine, même nombre d’étapes. On compare :
reward moyenne (alignement avec la cible implicite) ;
exactitude (argmax correct) ;
ECE top-label ;
Brier.
Verdict attendu :
binary : exactitude potentiellement élevée mais ECE très grand (sur-confiance extrême — pic de Dirac).
linear : intermédiaire (linéaire sans carré = Brier dégradé).
log / brier / spherical : ECE faible, calibration proche de la diagonale.
Si binary et linear donnent une calibration comparable aux propres, l’instrument est suspect. C’est le témoin négatif du organ-first (cf. organ-first-implementation.md).
@dataclassclass TrainResult: name: str kind: str rewards: list accs: list eces: list briers: listdef _reward_for(name, q, y):# Dedup issue #17897 : delegue a REWARDS (defini en cellule 4), source unique de l'arithmetique.if name notin REWARDS:raiseValueError(name)return REWARDS[name](q, y)def train_policy(reward_name, steps=1500, batch=256, lr=1e-2, seed=SEED): torch.manual_seed(seed) rng = np.random.default_rng(seed) pol = LogitPolicy(d=D, k=2).to(DEVICE) opt = torch.optim.Adam(pol.parameters(), lr=lr) hist_r, hist_a, hist_e, hist_b = [], [], [], []for step inrange(steps): x, y = make_batch(batch, rng) q = pol(x) r = _reward_for(reward_name, q, y) loss =-r.mean() opt.zero_grad(); loss.backward(); opt.step()if (step +1) %50==0or step ==0: x_eval, y_eval = make_batch(2000, rng)with torch.no_grad(): q_eval = pol(x_eval).cpu().numpy() y_eval_np = y_eval.cpu().numpy() acc =float((q_eval.argmax(axis=-1) == y_eval_np).mean()) ece = ece_top_label(y_eval_np, q_eval) brier = brier_multiclass(y_eval_np, q_eval) hist_r.append(float(r.mean().detach())) hist_a.append(acc) hist_e.append(ece) hist_b.append(brier)return TrainResult(reward_name, REWARD_KIND[reward_name], hist_r, hist_a, hist_e, hist_b)print('Entrainement par recompense (tache binaire, 1500 pas, lr=1e-2) :')print(f'{"name":10s}{"kind":8s}{"acc":>6s}{"ECE":>8s}{"Brier":>8s}')results_bin = []for name in REWARDS: r = train_policy(name) results_bin.append(r)print(f'{r.name:10s}{r.kind:8s}{r.accs[-1]:6.3f}{r.eces[-1]:8.4f}{r.briers[-1]:8.4f}')
Lecture attendue : log / brier / spherical (propres) calibrent mieux que binary / linear (impropres) à exactitude comparable. Si c’est l’inverse, c’est un signal que la tâche est trop facile (modèle atteint q=p en quelques pas) — pas un défaut de l’instrument.
6. Comparaison d’estimateurs — perturbation vs gradient direct
À récompense propre fixée (log-scorer), on compare deux procédures d’optimisation :
Perturbation gaussienne des logits (GRPO-like) : on tire G logits bruités logits + σ · ε (ε ~ N(0, I)), on échantillonne une action par logit, on calcule la récompense, puis on pondère log π(action) par l’avantage relatif au groupe.
Gradient direct : ∂ log q(y|x) / ∂ θ est connu quand y est observé. C’est le maximum de vraisemblance standard.
Erratum — prédiction réfutée par la sortie ci-dessous. La version initiale annonçait un gradient direct de variance 2 à 3 fois plus faible. Sur les 20 dernières pertes enregistrées, l’écart-type affiché est au contraire 0,0353 pour le direct contre 0,0040 pour la perturbation, soit environ 8,8 fois plus élevé pour le direct. Ce sont des écarts-types de pertes, pas des estimations de variance du gradient : comparer deux losses d’échelles et d’objectifs différents (NLL contre avantage relatif au groupe) ne permet pas de conclure qu’un estimateur de gradient est moins variable. La prédiction « 50 % moins de pas à précision donnée » n’est pas non plus mesurée ici. La section 8 compare plutôt les deux procédures sur le même jeu d’évaluation : ECE 0,0113 contre 0,0504, et Brier 0,2557 contre 0,2690, en faveur du direct pour la calibration dans cette expérience à graine unique.
Comparaison estimateurs (log-scorer) :
Direct : gradient de cross_entropy(logits, y)
Perturb. : GRPO-like, G=8, sigma=0.5
Direct : loss finale = 0.4002, std sur les 20 derniers = 0.0353
Perturb. : loss finale = -0.0118, std sur les 20 derniers = 0.0040
Mesures finales (meme eval set, 4000 exemples) :
Direct : acc=0.8145, ECE=0.0113, Brier=0.2557
Perturb. : acc=0.8187, ECE=0.0504, Brier=0.2690
7. Cas ordinal — surrogate log (RPS non différentiable exact)
laya/common.py::proper_reward combine log + λ · sphérique − RPS sur les questions ordinales. RPS = somme cumulée des écarts carrés entre les CDF prédites et observées :
RPS(q, y) = Σ_{k=1..K-1} (F_q(k) − F_y(k))²
où F_q(k) = Σ_{j≤k} q(j), F_y(k) = Σ_{j≤k} 1[y ≤ k]. C’est strictement propre pour les cibles ordinales.
Surrogate log partagé (clef de lecture) : RPS n’est pas différentiable directement sur la sortie softmax. On utilise donc un seul surrogate differentiable, log_scorer = Σ_k y_k · log q_k (logréchantillon cross-entropique élément-wise), comme approximation différentiable du RPS, du log-scorer et du MLE cross-entropy. Cela explique pourquoi cross_ent, log et rps produisent strictement le même résultat dans la cellule code suivante : ce sont trois noms pour la même loss effective.
Cas jouet : K=4 classes ordinales, on compare en réalité deux familles distinctes :
Famille log-vraisemblance (cross_ent, log, rps — surrogate log partagé, équivalentes par construction) ;
Famille sphérique (spherical — produit scalaire normalisé, géométriquement distincte).
RPS est propre sur cibles ordinales ; ici on l’applique en classification 4-classes traitée comme ordinale — c’est ce que fait laya sur ses questions score. RPS differentiable exact demanderait un surrogate custom ; on utilise log_scorer comme surrogate (verrou de continuité déclaré).
def train_policy_cat(reward_name, steps=1500, batch=256, lr=1e-2, seed=SEED): torch.manual_seed(seed) rng = np.random.default_rng(seed) pol = LogitPolicy(d=D, k=K4).to(DEVICE) opt = torch.optim.Adam(pol.parameters(), lr=lr) accs, eces, briers = [], [], []for step inrange(steps): x, y = make_batch_cat(batch, rng) q = pol(x)if reward_name =='log'or reward_name =='spherical':# Dedup issue #17897 : log/spherical multi-classes utilisent la meme arithmetique# que la version binaire (cell 4). REWARDS[name] est la source unique. r = REWARDS[reward_name](q, y)elif reward_name =='rps':# rps (Ranked Probability Score) = log multi-classes ; propre au cas 4-classes,# pas de doublon avec reward_log (eps inline pour stabilite numerique). k = q.shape[-1] yh = to_onehot(y, k) r = (yh * torch.log(q +1e-9)).sum(dim=-1)elif reward_name =='cross_ent': logits = pol.net(x) r =-F.cross_entropy(logits, y, reduction='none')else:raiseValueError(reward_name) loss =-r.mean() opt.zero_grad(); loss.backward(); opt.step()if (step +1) %50==0or step ==0: x_eval, y_eval = make_batch_cat(2000, rng)with torch.no_grad(): q_eval = pol(x_eval).cpu().numpy() y_eval_np = y_eval.cpu().numpy() accs.append(float((q_eval.argmax(axis=-1) == y_eval_np).mean())) eces.append(ece_top_label(y_eval_np, q_eval)) briers.append(brier_multiclass(y_eval_np, q_eval))return accs, eces, briersprint('Entrainement 4-classes ordinal (1500 pas, lr=1e-2) — 2 familles :')print(' - log-vraisemblance : cross_ent / log / rps (surrogate log partage, equivalents par construction)')print(' - spherique : spherical (produit scalaire normalise, distinct)')print()print(f'{"famille":18s}{"reward":12s}{"acc":>6s}{"ECE":>8s}{"Brier":>8s}')results_ord = {}# Famille 1 — log-vraisemblance : surrogate log partage, 'cross_ent', 'log', 'rps' sont equivalentsfor family_name, names in [('log-vraisemblance', ['cross_ent', 'log', 'rps']), ('spherique', ['spherical'])]: name = names[0] # nom canonique de la famille ; les autres noms sont equivalents par construction accs, eces, briers = train_policy_cat(name)print(f'{family_name:18s}{name:12s}{accs[-1]:6.3f}{eces[-1]:8.4f}{briers[-1]:8.4f}')for n in names: results_ord[n] = (accs, eces, briers)
ECE moyen impropres : 0.0877. ECE moyen propres : 0.0115. Gain : -0.0763 (les propres calibrent 7.6x mieux, exactitude comparable). C’est la difference entre une distribution rapportee qui suit p(y|x) et une distribution piquee sur l’argmax.
Estimateurs (log-scorer, 800 pas) :
Estimateur
loss finale
std 20 derniers
ECE final
Brier final
Direct (MLE)
0.4002
0.0353
0.0113
0.2557
Perturbation G=8
-0.0118
0.0040
0.0504
0.2690
La perturbation atteint une loss policy-gradient plus basse, mais elle calibre moins bien (ECE 4.5x superieur) : sa loss mesure l’avantage relatif au groupe, pas la vraisemblance. Le gradient direct minimise la NLL – c’est la meme famille que le log-scorer, et il calibre.
Cas ordinal (4-classes, 1500 pas, RPS via surrogate log comme declare) – 2 familles :
Pour les cibles ordinales, le verrou de continuite du RPS vers log-scorer tient : les 3 noms de la famille log-vraisemblance produisent le meme resultat (memes loss, meme surrogate, meme gradient) – c’est attendu par construction, pas une coincidence empirique. La famille spherique a une legere degradation ECE (0.029 vs 0.021) : sur 4-classes ordinales, la metrique spherique n’apporte pas la meme stabilite de calibration que le surrogate log. C’est l’opposition qu’on cherche a montrer.
Ce que le notebook tranche :
Les recompenses propres (log, Brier, spherique) calibrent 7.6x mieux que les impropres (binary, linear) a exactitude comparable.
La sur-confiance extreme de binaryn’est pas la propriete de la cross-entropie – c’est la propriete de toute recompense qui ne note que l’argmax. La cross-entropie est strictement propre.
A recompense propre fixee, le gradient direct est plus stable en calibration que la perturbation GRPO-like. La loss policy-gradient peut etre trompeuse.
La famille des regles propres ordinales (RPS) se reduit a un surrogate differentiable unique : la cellule §7 montre qu’il y a 2 familles en jeu (log-vraisemblance vs spherique), pas 4 – les colonnes identiques du tableau precedent etaient un artefact, pas une information.
Suite – Etage 2 (PR separee) :
Fine-tuning de laya-multilingual (encodeur mmBERT-base) sur un sous-ensemble borne.
Trois bras : cross-entropie seule / RL seul / RL + cross-entropie (recette publiee).
= 4 graines (0/1/7/42/99), GPU 24 Go (registre #16737).
Verdict §C : BEATS / NO BEATS / INCONCLUSIVE avec multi-seed.
Bibliographie a archiver sur GDrive (chemins cites dans la PR de l’etage 2) :
Gneiting & Raftery (2007), JASA 102(477) ;
Guo, Pleiss, Sun & Weinberger (2017), arXiv:1706.04599 ;
Nandakishor M., arXiv:2503.23303 et arXiv:2510.01237 ;
Billet dev.to de l’auteur, capture PDF datee ;
Capture PDF datee de l’annonce commerciale du 15/09/2026.
Exercice — Temperature scaling
Le modele est sous-confiant ou sur-confiant apres optimisation par recompense propre ? La temperature scaling post-hoc est une calibration lineaire en logit : q_T(y|x) = softmax(log q(y|x) / T).
Question : pour chaque recompense, trouver la temperature T qui minimise l’ECE sur l’eval set. Indication : grille T in [0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0].
# Exercice 1 : temperature scaling post-hoc pour chaque recompense.## Schema :# 1. Pour chaque modele dans results_bin, faire une boucle sur la grille T.# 2. Pour chaque T, recalculer q_T = softmax(log(q)/T), puis ECE.# 3. Garder le meilleur T par recompense.## Pour reexecuter un modele, il faudrait le stocker dans train_policy (modifier le code).# Ici, on demande a l'etudiant d'instrumenter la derniere boucle :# - ajouter `q_eval_detached = q_eval.detach()` dans train_policy# - stocker dans TrainResult (nouveau champ `q_eval_final: np.ndarray`)# - appliquer la recherche de T ici.## Stubs : laisser None pour les valeurs a remplir.import numpy as npdef ece_top_label_np(y_true_idx, q_proba, n_bins=10):"""Version numpy pure (reimplementation pour eviter les tenseurs).""" bin_edges = np.linspace(0.0, 1.0, n_bins +1) bin_centers =0.5* (bin_edges[:-1] + bin_edges[1:]) q_max = q_proba.max(axis=-1) pred = q_proba.argmax(axis=-1) correct = (pred == y_true_idx).astype(np.float64) bin_idx = np.digitize(q_max, bin_edges[1:-1]) observed_freq = np.full(n_bins, np.nan) bin_count = np.zeros(n_bins, dtype=np.int64)for b inrange(n_bins): mask = bin_idx == bif mask.any(): observed_freq[b] = correct[mask].mean() bin_count[b] =int(mask.sum()) n =len(y_true_idx) ece =0.0for b inrange(n_bins):if bin_count[b] >0: ece += bin_count[b] / n *abs(bin_centers[b] - observed_freq[b])returnfloat(ece)# Stub : a completer par l'etudiantdef best_temperature(y_true_idx, q_proba, grid=(0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0)):"""Renvoie (T_optimal, ECE_optimal) parmi la grille. T_optimal = None # TODO etudiant : argmin ece_top_label sur la grille ECE_optimal = None # TODO etudiant """pass# Test : si la fonction est completee, le print donnera le T optimaltry: out = best_temperature(y_rand, q_rand)if out isNone:print('Exercice 1 a completer : best_temperature renvoie None')else: T_opt, ece_opt = outprint(f'Temperature optimale : T={T_opt:.2f}, ECE={ece_opt:.4f}')exceptExceptionas e:print(f'Exercice 1 a completer : {type(e).__name__}: {e}')
Exercice 1 a completer : best_temperature renvoie None
Pour aller plus loin — l’étage 2 du chantier laya
Étape suivante — PT-18 — laya étage 2 : l’ablation à trois bras : sur le vrai checkpoint laya-multilingual, la recette publiée (rl_ce) est décomposée en bras qui ne diffèrent que par la perte (ce seule, rl seule, rl_ce), grille 3 bras × 4 graines — le terme RL n’apporte rien de mesurable au-delà de la cross-entropie sur ce benchmark.