Comparer deux versions
Le 2026-10-15, gemini-3.8-flash et gemini-3.5-flash-lite obtiennent exactement 0,867 en priorité sur les 30 tickets. Le 2026-10-16, mêmes modèles, même prompt, même jeu, servis par Vertex AI : 0,867 contre 0,933. Une équipe lirait « égalité » le premier jour et « le modèle léger est meilleur » le second. Les deux lectures sont fausses : sur 30 cas, ni l'égalité ni l'écart de 6,7 points ne sont établis. Comparer deux moyennes sans marge d'erreur, c'est lire du bruit.
Le mécanisme : apparier, rééchantillonner, corriger
Trois idées, dans l'ordre où le code les applique (stats.py, compare.py) :
- Différences appariées. A et B passent sur les mêmes cas : on calcule, cas par cas,
d = score_B − score_A. La variabilité due à la difficulté des cas s'annule (un ticket ambigu fait échouer les deux), et l'incertitude ne dépend plus que des cas où les deux versions divergent. - Intervalle de confiance par bootstrap. On tire 10 000 fois 30 cas avec remise parmi les 30 différences, on calcule la moyenne de chaque tirage, et on garde les percentiles 2,5 et 97,5. Aucune hypothèse de loi normale, et un IC qui reste dans [−1, 1].
- Test de permutation et correction de Holm. Sous l'hypothèse « A et B sont interchangeables », le signe de chaque différence est arbitraire : on l'inverse au hasard 10 000 fois et on regarde si l'écart observé est rare. Avec 8 métriques testées, la probabilité qu'au moins une paraisse significative par hasard approche 1 − 0,95⁸ ≈ 34 % ; Holm ajuste les p-valeurs pour ramener ce risque à 5 %.
flowchart LR
A["results A : 30 scores"] --> D["d = B - A, cas par cas"]
B["results B : 30 scores"] --> D
D --> BS["bootstrap 10 000 tirages : IC 95 %"]
D --> PM["permutation de signes : p"]
PM --> H["Holm sur 8 métriques : p ajusté"]
BS --> NI{"borne basse >= -marge ?"}
NI -->|"oui"| OK["non-infériorité établie"]
NI -->|"non"| KO["dégradation non exclue : porte bloquée"]
H --> R["rapport : significatif ou non"]
La porte ne demande pas « B est-il significativement pire ? » mais « B est-il démontré non inférieur ? ». La différence est décisive. Sur 30 cas, une régression de 10 points n'est presque jamais significative : une porte qui attend la significativité laisse tout passer. La non-infériorité renverse la charge de la preuve : la borne basse de l'IC de B − A doit rester au-dessus de −marge, la marge étant la perte maximale acceptable, fixée par le métier avant l'expérience.
La mise en œuvre
means = d[rng.integers(0, d.size, size=(n_boot, d.size))].mean(axis=1) # 10 000 x 30 en une opération
lo, hi = np.quantile(means, [alpha / 2, 1 - alpha / 2])
d = d[d != 0] # cas identiques : aucune information
signs = rng.choice([-1.0, 1.0], size=(n_perm, d.size))
perm = np.abs((signs * d).mean(axis=1))La graine est fixe (seed=0) : le même couple d'exécutions produit le même rapport, au chiffre près, sur tous les postes.
.venv/bin/python -m evalh compare runs/triage/A runs/triage/B --suite suites/triage.yaml --html runs/rapport-triage.htmlRésultat obtenu (2026-10-15, API Gemini)
métrique A B B-A IC95 p_holm
categorie 1.000 1.000 +0.000 [+0.000, +0.000] 1.000
priorite 0.867 0.867 +0.000 [-0.133, +0.133] 1.000
similarite 0.895 0.882 -0.013 [-0.028, +0.001] 0.799
juge_reponse 1.000 1.000 +0.000 [+0.000, +0.000] 1.000
cas qui changent : 10 / 30
ÉCHEC non-infériorité priorite B-A +0.000, IC95 [-0.133, +0.133], marge -0.1
PORTE BLOQUÉE(Extrait : les métriques saturées à 1,000 sont omises.) Les deux modèles se trompent sur 4 priorités chacun, mais pas les mêmes : A sur T24 et T26, B sur T29 et T30, les deux sur T16 et T23. Quatre cas discordants sur 30 suffisent à produire un IC de ±0,133.
Le lendemain, sur Vertex AI (2026-10-16)
métrique A B B-A IC95 p_holm
categorie 0.967 1.000 +0.033 [+0.000, +0.100] 1.000
priorite 0.867 0.933 +0.067 [+0.000, +0.167] 1.000
similarite 0.904 0.885 -0.019 [-0.031, -0.008] 0.021
juge_reponse 1.000 0.933 -0.067 [-0.167, +0.000] 1.000
cas qui changent : 7 / 30
ÉCHEC non-infériorité juge_reponse B-A -0.067, IC95 [-0.167, +0.000], marge -0.1
PORTE BLOQUÉELe verdict tient, la raison change. Cette fois gemini-3.5-flash-lite promet le remboursement du casque de 249 € « 5 jours ouvrés après réception », sans la validation d'un responsable qu'exige tout montant au-delà de 150 € (T08), et invente qu'« notre équipe logistique prépare actuellement votre colis » (T16). Le modèle léger n'est pas « équivalent avec du bruit » : il produit des violations de politique qui apparaissent dans une exécution et pas dans l'autre, à température 0, et 30 cas ne permettent pas d'en chiffrer la fréquence.
Combien de cas faudrait-il ?
Si les deux versions sont réellement équivalentes, la variance d'une différence appariée binaire vaut le taux de cas discordants. La fonction cases_needed en déduit la taille du jeu :
.venv/bin/python -c "from evalh.stats import cases_needed as n; print(n(4/30, 0.10, power=0.5), n(4/30, 0.10))"52 105Avec 13 % de cas discordants et une marge de 10 points, il faut 52 cas pour que l'IC « moyen » tienne juste dans la marge, et 105 pour qu'un candidat réellement équivalent franchisse la porte 8 fois sur 10. Avec 30 cas, la porte bloque un modèle équivalent la plupart du temps. C'est le comportement voulu pour une porte prudente, et c'est un coût : chaque exécution de 105 cas coûte environ 3,5 fois celle de 30.
La porte bloque gemini-3.5-flash-lite sur la priorité avec B − A = +0,000 et IC [−0,133 ; +0,133]. Le directeur financier conclut que le harness est « trop sévère » et demande de baisser la marge à −0,15. Que répondez-vous ?
La porte ne dit pas que B est pire, elle dit que 30 cas ne permettent pas d'exclure une perte de 13 points. Les deux leviers honnêtes sont la marge (une décision métier, prise avant de voir les résultats) et la taille du jeu. Relancer jusqu'à ce que ça passe est du p-hacking : avec assez d'essais, un tirage favorable finit toujours par arriver.
Sur Vertex, la similarité baisse de 0,019 avec p Holm 0,021 : la seule métrique significative du rapport. Le chef de produit veut ajouter la similarité aux marges de la porte. Que conseillez-vous ?
Significatif veut dire « probablement pas du hasard », pas « important ». Une réponse plus courte, ou mieux formulée que la référence, fait aussi baisser le cosinus. La p-valeur est déjà corrigée par Holm pour les 8 métriques : elle n'est pas forcément un faux positif. La similarité sert à orienter la lecture du diff ; la décision repose sur des métriques qui mesurent ce que le métier exige.
Ce qui casse en production
- Comparaison non appariée. Évaluer A sur 30 cas et B sur 30 autres, ou sur un jeu modifié entre-temps, mélange la difficulté des cas et l'effet du changement.
comparerefuse deux exécutions d'empreintes différentes (code 2) et deux exécutions dont les cas diffèrent (un--limitoublié). - Rejouer jusqu'à franchir. Le cache rend une relance gratuite et identique, ce qui protège contre ce travers. Une équipe qui lance
--no-cacheen boucle sur la CI jusqu'à obtenir un vert pratique la même chose qu'un test multiple non corrigé. Journalisez chaque exécution de la porte, pas seulement la dernière. - IC nul sur une métrique saturée.
commandedonne [+0,000 ; +0,000] : le bootstrap ne voit aucune variabilité, puisque aucun cas ne diffère. Cela ne prouve pas que la probabilité d'erreur est nulle. Sur 30 succès sur 30, la borne supérieure du taux d'erreur à 95 % reste de l'ordre de 10 % (règle de trois : 3/n). - Bootstrap percentile sur petit échantillon. Sous 20 à 30 cas, l'IC percentile est un peu trop étroit (Miller, 2024). Pour une décision coûteuse sur un petit jeu, préférez une marge plus prudente ou agrandissez le jeu.
- Significatif mais hors porte. La similarité baisse de façon significative (p Holm 0,021 sur Vertex) sans figurer dans les marges. Le rapport la signale ; c'est l'humain qui lit les cas concernés.
La règle de décision
Comparez toujours deux versions sur les mêmes cas, le même jour, avec le même backend. Fixez les marges de non-infériorité par métrique avant la première exécution, avec le métier, et calculez la taille du jeu qu'elles exigent (cases_needed). Lisez la significativité corrigée par Holm pour annoncer une amélioration, la non-infériorité pour autoriser un changement. Surveillez le taux de cas discordants : c'est lui, plus que la moyenne, qui dicte la taille du jeu.
Pratique
Exercice. Le métier exige qu'un changement ne fasse pas perdre plus de 5 points sur juge_reponse. Sur Vertex, 2 cas sur 30 diffèrent entre A et B pour cette métrique. Combien de cas faut-il, et que concluez-vous sur la faisabilité ?
Voir la solution
.venv/bin/python -c "from evalh.stats import cases_needed as n; print(n(2/30, 0.05))"210210 cas, soit 7 fois le jeu actuel, et autant de verdicts du juge à chaque exécution (de l'ordre de 0,90 USD de juge et d'embeddings par version, cache vide, d'après les 0,13 USD mesurés pour 30 cas). C'est faisable une fois par semaine sur la branche principale, pas à chaque commit. Le compromis courant : un jeu rapide de 30 à 50 cas sur chaque PR avec une marge large, et le jeu complet en tâche planifiée avant chaque mise en production.
- J'explique pourquoi l'appariement réduit l'incertitude d'une comparaison.
- Je lis un IC bootstrap et je sais ce qu'il ne dit pas sur une métrique saturée.
- Je distingue non-infériorité (pour autoriser) et significativité (pour annoncer un gain).
- Je calcule la taille de jeu qu'exige une marge avec
cases_needed. - Je refuse de relancer une évaluation jusqu'à ce qu'elle passe.