22 — Évaluer les sorties générées : BLEU, ROUGE, perplexité et juge LLM

Navigation : FT-01 n’est pas dans cette série — pour la série Texte : 21_LoRA_FineTuning · 22 (ce notebook) | Suivant >>

Pourquoi ce notebook existe

La série Texte produit des sorties génératives (RAG, raisonnement, génération structurée) mais aucun notebook ne répond à la question qui suit immédiatement : cette sortie est-elle bonne ? Les notebooks d’entraînement montrent que le réflexe manque au catalogue lui-même — un « prefAcc 40 % » non interprété, un « 100 % » mesuré sur six paires. Ce notebook installe tôt le réflexe d’évaluation rigoureuse chez l’apprenant.

Trois familles d’outils, trois angles sur la même question :

  1. Métriques lexicales (BLEU, ROUGE) — objectives, reproductibles, reproductibles par vous : nous les implémentons from scratch puis nous vérifions chaque valeur contre une librairie de référence ;
  2. Perplexité — le lien direct entre « prévisible » et la loss d’entraînement d’un LLM ;
  3. Juge LLM — puissant mais biaisé : nous le fauchons en plein vol. Biais de position, biais de verbosité, effet de la température — démontrés empiriquement sur notre stack self-hosted (vLLM, Qwen2.5-0.5B-Instruct), verdicts réels multi-températures, pas d’évaluation simulée.

Enfin une application RAG : fidélité au contexte (chaque affirmation de la réponse est-elle soutenable par les documents ?) et rappel des faits — deux métriques de la réponse générée, à distinguer des métriques de retrieval (recall@k, nDCG) qui mesurent la recherche et non la réponse.

# Imports et configuration — venv GPU local, client vers le serveur vLLM self-hosted.
# (les warnings tqdm/hub peuvent embarquer des chemins machine -> supprimes)
import os
import warnings
warnings.filterwarnings("ignore", message="IProgress not found")
warnings.filterwarnings("ignore", message="huggingface_hub.*symlinks.*")
warnings.filterwarnings("ignore", message="torch.utils.checkpoint: the use_reentrant.*")

import math
import re
from collections import Counter

from openai import OpenAI

LOCAL_BASE_URL = "http://127.0.0.1:8185/v1"
LOCAL_MODEL_ID = "Qwen2.5-0.5B-Instruct-local"

client_local = OpenAI(base_url=LOCAL_BASE_URL, api_key="no-key-required")

# Sonde : le serveur local doit repondre (sinon le notebook ne peut pas tenir
# ses promesses de juges reels).
models = [m.id for m in client_local.models.list()]
print("Modeles disponibles sur le serveur local :", models)
assert LOCAL_MODEL_ID in models, (
    f"Le serveur vLLM ({LOCAL_BASE_URL}) n'expose pas {LOCAL_MODEL_ID}. "
    "Demarrer le serveur avant d'executer ce notebook (regle F : reparer, pas contourner)."
)
print("Juge local operationnel.")
Modeles disponibles sur le serveur local : ['Qwen2.5-0.5B-Instruct-local']
Juge local operationnel.

1. BLEU from scratch — la précision n-gram, décomposée

BLEU (Papineni et al., 2002) mesure la précision modifiée : quelle part des n-grammes de la candidate apparaît dans la référence ? Deux ingrédients :

  • précision n-gram modifiée pour n = 1..4 : on compte chaque n-gramme de la candidate, mais plafonné par son nombre d’occurrences dans la référence (le clipping — sinon « le le le le » marquerait 100 % face à « le chat ») ;
  • brevity penalty : une candidate trop courte triche (précision facile sur peu de mots), donc BP = exp(1 − r/c) si la candidate c est plus courte que la référence r, sinon 1.

Le score final est la moyenne géométrique des précisions 1..4, multipliée par BP. Chaque composant ci-dessous est vérifiable à la main sur nos exemples courts — c’est le but.

def ngrams(tokens, n):
    return Counter(tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1))

def modified_precision(candidate, reference, n):
    """Precision n-gram avec clipping : min(compte candidate, compte reference)."""
    cand_ngrams = ngrams(candidate, n)
    ref_ngrams = ngrams(reference, n)
    overlap = sum(min(c, ref_ngrams[g]) for g, c in cand_ngrams.items())
    total = max(sum(cand_ngrams.values()), 1)
    return overlap, total

def bleu_from_scratch(candidate_tokens, reference_tokens, max_n=4, epsilon=0.1):
    """BLEU decompose : precisions par n, moyenne geometrique, brevity penalty.

    Le lissage replicate exactement nltk SmoothingFunction().method1
    (nltk 3.10.3, relu a la source) : un ordre n sans recouvrement remplace
    p par (0 + epsilon)/total avec epsilon = 0.1 — sinon la moyenne
    geometrique s'effondre a exactement 0 au premier ordre vide.
    """
    precisions = []
    detail = []
    for n in range(1, max_n + 1):
        overlap, total = modified_precision(candidate_tokens, reference_tokens, n)
        p = (overlap + epsilon) / total if overlap == 0 else overlap / total
        precisions.append(p)
        detail.append((n, overlap, total, p))
    c, r = len(candidate_tokens), len(reference_tokens)
    bp = math.exp(1.0 - r / c) if c <= r else 1.0
    log_mean = sum(math.log(p) for p in precisions) / len(precisions)
    return bp * math.exp(log_mean), bp, detail

# Exemple lisible a la main : traduction avec recouvrement partiel.
cand = "the cat is on the mat".split()
ref = "the cat sits on the red mat".split()

score, bp, detail = bleu_from_scratch(cand, ref)
print(f"candidate ({len(cand)} mots) : {' '.join(cand)}")
print(f"reference ({len(ref)} mots) : {' '.join(ref)}")
print()
for n, overlap, total, p in detail:
    print(f"  precision {n}-gram : {overlap}/{total} = {p:.4f}" + ("  (lissee)" if overlap == 0 else ""))
print(f"  brevity penalty      : {bp:.4f}  (c={len(cand)}, r={len(ref)})")
print(f"  BLEU-4               : {score:.4f}")
candidate (6 mots) : the cat is on the mat
reference (7 mots) : the cat sits on the red mat

  precision 1-gram : 5/6 = 0.8333
  precision 2-gram : 2/5 = 0.4000
  precision 3-gram : 0/4 = 0.0250  (lissee)
  precision 4-gram : 0/3 = 0.0333  (lissee)
  brevity penalty      : 0.8465  (c=6, r=7)
  BLEU-4               : 0.1093

Lecture du resultat BLEU

La decomposition vaut plus que le score final. Ici la precision 1-gram est haute (presque chaque mot de la candidate est dans la reference) mais elle tombe vite avec n : a l’ordre 3 et 4, les n-grammes exacts (“is on the” vs “sits on the”) disparaissent, et sans lissage la moyenne geometrique vaudrait exactement 0 — c’est method1 (epsilon=0.1) qui releve ces ordres vides a 0.025 et 0.033. C’est BLEU qui sanctionne la fidelite locale a l’ordre, pas juste le vocabulaire. La brevity penalty mord aussi : la candidate (6 mots) est plus courte que la reference (7 mots), BP = exp(1 - 7/6) ~ 0.85 ; retrecissez la candidate a trois mots et observez-la s’aggraver.

Un reflexe d’hygiene : rapporter les composants (precisions par n, BP) avec le score, jamais le score seul. Deux BLEU-4 identiques peuvent cacher des profils tres differents - l’un precis sur les petits n, l’autre sauve par le lissage.

Vérification contre une librairie de référence

Une implémentation pédagogique n’a de valeur que si elle reproduit les valeurs de référence. Nous comparons notre BLEU à nltk.translate.bleu_score avec le même lissage (method1, add-epsilon) sur les mêmes paires — la concordance doit être exacte, pas « à peu près ».

from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction

verif_cases = [
    ("the cat is on the mat", "the cat sits on the red mat"),
    ("the quick brown fox jumps over the lazy dog", "a fast brown fox leaps over a sleepy dog"),
    ("breaking news the market closed higher today", "the market closed higher today after strong earnings"),
]

print(f"{'paire':<58} {'from scratch':>13} {'nltk':>9}  {'match':>6}")
all_ok = True
for c_text, r_text in verif_cases:
    ours, _, _ = bleu_from_scratch(c_text.split(), r_text.split())
    ref_val = sentence_bleu([r_text.split()], c_text.split(),
                            smoothing_function=SmoothingFunction().method1)
    ok = abs(ours - ref_val) < 1e-9
    all_ok &= ok
    print(f"{c_text[:40]}... vs {r_text[:12]}... {ours:>13.6f} {ref_val:>9.6f}  {'OK' if ok else 'ECART':>6}")
assert all_ok, "divergence vs nltk -- implémentation a corriger"
print()
print("Concordance exacte sur", len(verif_cases), "paires (method1, epsilon=0.1).")
paire                                                       from scratch      nltk   match
the cat is on the mat... vs the cat sits...      0.109280  0.109280      OK
the quick brown fox jumps over the lazy ... vs a fast brown...      0.060307  0.060307      OK
breaking news the market closed higher t... vs the market c...      0.532946  0.532946      OK

Concordance exacte sur 3 paires (method1, epsilon=0.1).

Chaque valeur from scratch doit etre exactement celle de la lib - au 1e-9 pres, pas au “a peu pres”. C’est le contrat d’une implementation pedagogique : elle n’existe que pour rendre la boite noire lisible ; si elle diverge de la reference, c’est elle qui a tort. Notez que le lissage (methode1) est indispensable pour la comparaison : sans lui, un seul ordre d’n-gramme sans recouvrement met tout le score a zero et la comparaison perd son sens.


2. ROUGE from scratch — le rappel, miroir de BLEU

ROUGE (Lin, 2004) retourne la question : au lieu de demander « quelle part de la candidate est dans la référence » (précision), il demande « quelle part de la référence est couverte par la candidate » (rappel). C’est le critère naturel du résumé : rater un fait de la référence coûte cher, en dire trop coûte moins. ROUGE-L mesure la plus longue sous-séquence commune (ordre préservé, trous autorisés) — plus tolérant à la rephrasing que les n-grammes stricts.

Quand utiliser laquelle ?

Tâche Métrique naturelle Pourquoi
Traduction BLEU (précision) la sortie ne doit rien inventer
Résumé ROUGE (rappel) chaque fait de la source doit être couvert
RAG (réponse) les deux + fidélité au contexte (section 5) viser les faits, sans inventer

La démonstration ci-dessous rend la symétrie visible : une candidate courte mais exacte a une précision élevée et un rappel faible ; une candidate verbeuse qui noie les faits fait l’inverse.

def tokenize_simple(text):
    """Meme tokenisation que rouge_score : minuscules + [a-z0-9]+."""
    return re.findall(r"[a-z0-9]+", text.lower())

def rouge_n_from_scratch(reference, candidate, n=1):
    """ROUGE-n : precision et rappel des n-grammes (clipping par Counter)."""
    ref_ngrams = ngrams(reference, n)
    cand_ngrams = ngrams(candidate, n)
    inter = sum(min(c, ref_ngrams[g]) for g, c in cand_ngrams.items())
    precision = inter / max(sum(cand_ngrams.values()), 1)
    recall = inter / max(sum(ref_ngrams.values()), 1)
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
    return precision, recall, f1

def lcs_length(a, b):
    """Plus longue sous-sequence commune (dynamique, ordre preserve, trous OK)."""
    table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            table[i][j] = table[i-1][j-1] + 1 if a[i-1] == b[j-1] else max(table[i-1][j], table[i][j-1])
    return table[len(a)][len(b)]

def rouge_l_from_scratch(reference, candidate):
    lcs = lcs_length(reference, candidate)
    precision = lcs / max(len(candidate), 1)
    recall = lcs / max(len(reference), 1)
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
    return precision, recall, f1

ref_text = "the market closed higher today after strong earnings in the technology sector"
short_exact = "the market closed higher today"                       # courte mais exacte
verbose_diluted = "the market closed higher today and many observers noted with great interest several details"  # faits noyes

for name, cand_text in [("courte-exacte", short_exact), ("verbeuse-diluee", verbose_diluted)]:
    p1, r1, f1 = rouge_n_from_scratch(tokenize_simple(ref_text), tokenize_simple(cand_text), 1)
    pl, rl, fl = rouge_l_from_scratch(tokenize_simple(ref_text), tokenize_simple(cand_text))
    print(f"candidate {name} ({len(cand_text.split())} mots) :")
    print(f"  ROUGE-1  precision={p1:.3f}  rappel={r1:.3f}  F1={f1:.3f}")
    print(f"  ROUGE-L  precision={pl:.3f}  rappel={rl:.3f}  F1={fl:.3f}")
print()
print("Lecture : la courte-exacte domine en precision, la verbeuse domine en rappel")
print("(elle couvre presque toute la reference) mais perd en precision (dilution).")
candidate courte-exacte (5 mots) :
  ROUGE-1  precision=1.000  rappel=0.417  F1=0.588
  ROUGE-L  precision=1.000  rappel=0.417  F1=0.588
candidate verbeuse-diluee (14 mots) :
  ROUGE-1  precision=0.357  rappel=0.417  F1=0.385
  ROUGE-L  precision=0.357  rappel=0.417  F1=0.385

Lecture : la courte-exacte domine en precision, la verbeuse domine en rappel
(elle couvre presque toute la reference) mais perd en precision (dilution).

Lecture de la symetrie precision/rappel

La candidate courte-exacte marque haut en precision (presque tout ce qu’elle dit est juste) et bas en rappel (elle ne couvre pas “strong earnings” ni “technology sector”). La verbeuse-diluee fait exactement l’inverse : elle couvre presque toute la reference (rappel haut) en noyant les faits dans des mots qui ne servent a rien (precision basse). ROUGE-L raconte la meme histoire en tolerant l’ordre : la sous-sequence commune la plus longue preserve l’ordre des mots mais autorise les trous.

Le choix de la metrique est un choix de politique d’erreur : punir l’invention (traduction -> BLEU), punir l’omission (resume -> ROUGE), ou arbitrer les deux (F1, ou les deux scores cote a cote). Aucun n’est “meilleur” dans l’absolu - celui qui aligne la metrique sur le cout reel d’une erreur dans la tache visee.

Vérification contre rouge-score

Même discipline que pour BLEU : notre ROUGE-1 et ROUGE-L doivent reproduire la librairie rouge_score (celle de Google Research) au 1e-9 près, sur les mêmes paires, avec la même tokenisation.

from rouge_score import rouge_scorer

scorer = rouge_scorer.RougeScorer(["rouge1", "rougeL"], use_stemmer=False)

all_ok = True
for cand_text in (short_exact, verbose_diluted, "strong earnings in technology today lifted markets"):
    ref_toks, cand_toks = tokenize_simple(ref_text), tokenize_simple(cand_text)
    p1, r1, f1 = rouge_n_from_scratch(ref_toks, cand_toks, 1)
    pl, rl, fl = rouge_l_from_scratch(ref_toks, cand_toks)
    lib = scorer.score(ref_text, cand_text)
    ok = (abs(p1 - lib["rouge1"].precision) < 1e-9 and abs(r1 - lib["rouge1"].recall) < 1e-9
          and abs(f1 - lib["rouge1"].fmeasure) < 1e-9
          and abs(pl - lib["rougeL"].precision) < 1e-9 and abs(rl - lib["rougeL"].recall) < 1e-9
          and abs(fl - lib["rougeL"].fmeasure) < 1e-9)
    all_ok &= ok
    print(f"cand={cand_text[:45]:<47} R1={f1:.4f} (lib {lib['rouge1'].fmeasure:.4f}) "
          f"RL={fl:.4f} (lib {lib['rougeL'].fmeasure:.4f})  {'OK' if ok else 'ECART'}")
assert all_ok, "divergence vs rouge_score"
print()
print("Concordance exacte precision/rappel/F1 sur ROUGE-1 et ROUGE-L.")
cand=the market closed higher today                  R1=0.5882 (lib 0.5882) RL=0.5882 (lib 0.5882)  OK
cand=the market closed higher today and many obser   R1=0.3846 (lib 0.3846) RL=0.3846 (lib 0.3846)  OK
cand=strong earnings in technology today lifted ma   R1=0.5263 (lib 0.5263) RL=0.4211 (lib 0.4211)  OK

Concordance exacte precision/rappel/F1 sur ROUGE-1 et ROUGE-L.

3. La perplexité, à la main

La perplexité d’un modèle de langage sur une séquence est l’inverse de la probabilité moyenne par token, en échelle exponentielle :

\[\text{PPL}(w_1..w_T) = \exp\left(-\frac{1}{T}\sum_{t=1}^{T} \log p(w_t \mid w_{<t})\right)\]

Elle se lit comme « le nombre de choix effectifs entre lesquels le modèle hésite à chaque token » : PPL = 1 → certain, PPL = |vocab| → uniforme (aucune idée). Le lien avec l’entraînement est direct : la loss d’un LLM est l’entropie croisée moyenne, et PPL = exp(loss). Quand un notebook de fine-tuning rapporte une loss qui descend de 3.2 à 2.4, la perplexité passe de \(e^{3.2} \approx 24\) à \(e^{2.4} \approx 11\) — c’est la même information, dans une unité interprétable.

# Un petit modele bigramme jouet, appris a la main sur une mini-phrase d'entrainement.
train_sentence = "the cat sat on the mat the cat ate the fish"

unigram = Counter(train_sentence.split())
context = {}
words = train_sentence.split()
for prev, w in zip(words, words[1:]):
    context.setdefault(prev, Counter())[w] += 1

def next_token_prob(prev, w):
    """p(w | prev) estime par comptage bigramme avec lissage add-k."""
    k = 0.01
    vocab = list(unigram)
    counts = context.get(prev, Counter())
    total = sum(counts.values()) + k * len(vocab)
    return (counts.get(w, 0) + k) / total

def perplexity(sentence):
    toks = sentence.split()
    log_probs = [math.log(next_token_prob(prev, w)) for prev, w in zip(toks, toks[1:])]
    avg_neg_log = -sum(log_probs) / len(log_probs)          # entropie croisee moyenne (nats)
    ppl = math.exp(avg_neg_log)
    return ppl, avg_neg_log

for sent, label in [
    ("the cat sat on the mat", "phrase plausible (vue a l'entrainement)"),
    ("the fish ate the cat on the mat", "grammaticale mais inversee"),
    ("zebra quantum ate banana", "hors distribution"),
]:
    ppl, ce = perplexity(sent)
    print(f"PPL = {ppl:7.2f}  (cross-ent {ce:.3f} nats)  <- {label}")

# Reference : modele uniforme sur le vocabulaire observe.
V = len(unigram)
print()
print(f"Uniforme sur les {V} mots du vocabulaire : PPL = {V:.2f}")
print("Lecture : plus la perplexite s'approche de |V|, plus le modele est proche")
print("de l'ignorance totale ; un bon modele la tire vers le bas.")
PPL =    1.80  (cross-ent 0.586 nats)  <- phrase plausible (vue a l'entrainement)
PPL =    4.74  (cross-ent 1.555 nats)  <- grammaticale mais inversee
PPL =   17.37  (cross-ent 2.855 nats)  <- hors distribution

Uniforme sur les 7 mots du vocabulaire : PPL = 7.00
Lecture : plus la perplexite s'approche de |V|, plus le modele est proche
de l'ignorance totale ; un bon modele la tire vers le bas.

Lecture des perplexites

La phrase plausible obtient la perplexite la plus basse : ses bigrammes ont ete vus a l’entrainement, le modele hesite peu. La phrase inversee reste grammaticale mot a mot mais ses enchainements sont improbables - la perplexite monte. La phrase hors distribution explose : chaque token est une surprise, le modele en est reduit a presque l’uniforme.

La reference utile est la ligne du bas : le modele uniforme sur V mots a une perplexite exactement de V. Toute perplexite se lit comme “a quelle fraction de mon ignorance maximale je me suis reduit”. C’est aussi pourquoi les LLM rapportent leur loss en cross-entropy : exp(loss) = PPL, une simple reparametrisation - quand vous lirez “val loss 2.1”, traduisez instantanement “perplexite 8.2 : a chaque token, le modele hesite comme s’il choisissait entre ~8 suites equivalentes”.


4. Le juge LLM — puissant, et faux par endroits

Un LLM peut noter des réponses ouvertes là où BLEU/ROUGE sont aveugles (deux phrases peuvent dire la même chose avec zéro n-gramme commun). Mais le juge LLM apporte ses propres biais documentés (Zheng et al., Judging LLM-as-a-Judge, NeurIPS 2023) :

  • biais de position : préférer la réponse présentée en premier ;
  • biais de verbosité : préférer la réponse la plus longue, à contenu égal ;
  • auto-préférence : préférer les sorties de sa propre famille de modèles.

La réponse n’est pas d’abandonner le juge, mais de l’utiliser avec des garde-fous : rubrique structurée (critères × échelle) plutôt que note globale, température 0 pour le déterminisme, évaluation dans les deux ordres (A/B puis B/A) et agrégation symétrique, accord mesuré entre juges.

Toutes les cellules suivantes interrogent notre serveur vLLM self-hosted (Qwen2.5-0.5B-Instruct) — des verdicts réels, reproductibles, committés avec leurs outputs. Un juge de 0.5B est volontairement fragile : c’est un meilleur démonstrateur des biais qu’un gros juge qui les masque.

def judge_choice(question, answer_a, answer_b, temperature=0.0, client=None, model=None):
    """Juge binaire : quelle reponse est meilleure ? Repond 'A' ou 'B'.

    temperature=0 -> determinisme : meme question, meme verdict.
    """
    client = client or client_local
    model = model or LOCAL_MODEL_ID
    prompt = (
        "You are a strict judge. Compare the two answers to the question.\n"
        f"Question: {question}\n"
        f"Answer A: {answer_a}\n"
        f"Answer B: {answer_b}\n"
        "Which answer is better? Reply with exactly one letter: A or B."
    )
    r = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": "You are a strict judge. Answer with exactly one letter: A or B."},
            {"role": "user", "content": prompt},
        ],
        temperature=temperature,
        max_completion_tokens=16,
    )
    text = (r.choices[0].message.content or "").strip().upper()
    return "A" if text.startswith("A") else ("B" if text.startswith("B") else "?")

# Determinisme du juge a temperature 0 : 3 appels identiques, meme verdict ?
q = "What is the capital of Peru?"
verdicts = [judge_choice(q, "Lima.", "Paris.") for _ in range(3)]
print("Verdicts repetes (T=0) sur la meme paire :", verdicts)
print("Le juge est deterministe a temperature 0 :", len(set(verdicts)) == 1)
Verdicts repetes (T=0) sur la meme paire : ['A', 'A', 'A']
Le juge est deterministe a temperature 0 : True

4.1 Biais de position — démonstration par swap A/B

Le protocole : pour chaque paire (A, B), le juge évalue deux fois — (A,B) puis (B,A). Un juge non biaisé rend le même verdict relatif dans les deux ordres (si A gagne en premier, B doit perdre en second). Un renversement (le juge choisit la première option dans les deux ordres) prouve le biais de position. Nous affichons le taux de renversement sur 12 paires.

pairs = [
    ("What is the capital of Peru?", "Lima.", "Paris."),
    ("What is 7 multiplied by 8?", "56.", "54."),
    ("Who wrote Hamlet?", "William Shakespeare.", "Charles Dickens."),
    ("What is the chemical symbol of gold?", "Au.", "Ag."),
    ("How many continents are there?", "Seven.", "Five."),
    ("What gas do plants absorb from the atmosphere?", "Carbon dioxide.", "Oxygen."),
    ("What is the largest planet in the solar system?", "Jupiter.", "Mars."),
    ("In which city is the Eiffel Tower?", "Paris.", "London."),
    ("What is the boiling point of water at sea level?", "100 degrees Celsius.", "90 degrees Celsius."),
    ("Who painted the Mona Lisa?", "Leonardo da Vinci.", "Michelangelo."),
    ("What is the speed of light approximately?", "300,000 km per second.", "150,000 km per second."),
    ("What language is primarily spoken in Brazil?", "Portuguese.", "Spanish."),
]

flips = 0
results = []
for q, a, b in pairs:
    first = judge_choice(q, a, b)       # ordre (A, B)
    second = judge_choice(q, b, a)      # ordre (B, A) -- les etiquettes suivent les textes
    # verdict relatif : le gagnant selon chaque passe
    winner_first = first          # 'A' signifie que le texte A gagne
    winner_second = "B" if second == "A" else ("A" if second == "B" else "?")
    flipped = winner_first != winner_second
    flips += flipped
    results.append((q[:42], winner_first, winner_second, "RENVERSEMENT" if flipped else "stable"))

print(f"{'question':<44} {'1er ordre':>9} {'2e ordre':>9}  verdict")
for q, w1, w2, v in results:
    print(f"{q:<44} {w1:>9} {w2:>9}  {v}")
rate = flips / len(pairs)
print()
print(f"Taux de renversement : {flips}/{len(pairs)} = {rate:.0%}")
print("Un taux non nul = biais de position demontre empiriquement sur notre juge local.")
question                                     1er ordre  2e ordre  verdict
What is the capital of Peru?                         A         B  RENVERSEMENT
What is 7 multiplied by 8?                           A         B  RENVERSEMENT
Who wrote Hamlet?                                    A         B  RENVERSEMENT
What is the chemical symbol of gold?                 A         B  RENVERSEMENT
How many continents are there?                       A         B  RENVERSEMENT
What gas do plants absorb from the atmosph           A         B  RENVERSEMENT
What is the largest planet in the solar sy           A         B  RENVERSEMENT
In which city is the Eiffel Tower?                   A         B  RENVERSEMENT
What is the boiling point of water at sea            A         B  RENVERSEMENT
Who painted the Mona Lisa?                           A         B  RENVERSEMENT
What is the speed of light approximately?            A         B  RENVERSEMENT
What language is primarily spoken in Brazi           A         B  RENVERSEMENT

Taux de renversement : 12/12 = 100%
Un taux non nul = biais de position demontre empiriquement sur notre juge local.

Lecture du taux de renversement

Chaque ligne du tableau est une experience controlee : memes textes, seules les etiquettes d’ordre changent. Un juge parfait rend des verdicts relativement identiques dans les deux colonnes. Chaque RENVERSEMENT est une capture du biais en flagrant delit : le juge a choisi “la premiere option” deux fois, en contradiction avec lui-meme.

Le chiffre a retenir n’est pas tant sa valeur (elle varie avec le modele, la temperature, le lot) que le protocole : sans la double passe, ce biais est invisible - toutes les mesures paraissent saines. C’est le premier garde-fou a implementer dans tout pipeline de juge, avant meme de songer a des rubriques elaborees.

4.2 Biais de verbosité

À contenu égal, une réponse diluée par du remplissage (« it is important to note that… », « many observers agree… ») est souvent préférée par les juges LLM. Protocole : même contenu factuel, une version courte, une version avec bourrage ; on mesure la fréquence à laquelle le juge choisit la version longue.

verbosity_pairs = [
    ("What is the capital of France?",
     "Paris.",
     "It is widely known and universally recognized by geographers that the capital city of France is, without any doubt whatsoever, Paris."),
    ("What is 2 plus 2?",
     "4.",
     "When we carefully consider the fundamental principles of arithmetic and reflect on the operation known as addition, we arrive at the answer, which is 4."),
    ("Which planet is closest to the Sun?",
     "Mercury.",
     "According to the established model of our solar system, taking into account orbital distances that have been measured with great precision, the planet closest to the Sun is Mercury."),
    ("Who wrote the novel 1984?",
     "George Orwell.",
     "It is a matter of literary record, acknowledged by scholars and readers alike across many generations, that the author of the celebrated novel 1984 is George Orwell."),
    ("What is the chemical formula of water?",
     "H2O.",
     "Drawing upon the foundations of chemistry as established by centuries of scientific inquiry, we can state with complete confidence that the chemical formula of water is H2O."),
    ("How many sides does a triangle have?",
     "Three.",
     "Reflecting on the geometric definitions handed down since antiquity and confirmed by modern mathematics, a triangle has, by definition, three sides."),
]

verbose_wins = 0
for q, short, long in verbosity_pairs:
    choice = judge_choice(q, short, long)
    winner = "longue" if choice == "B" else "courte"
    verbose_wins += (choice == "B")
    print(f"{q[:48]:<50} juge -> {winner}")
print()
print(f"Preference pour la version verbeuse : {verbose_wins}/{len(verbosity_pairs)} = {verbose_wins/len(verbosity_pairs):.0%}")
print("Lecture : un taux eleve signe le biais de verbosite ; 'longue' gagne sans information supplementaire.")
What is the capital of France?                     juge -> courte
What is 2 plus 2?                                  juge -> courte
Which planet is closest to the Sun?                juge -> courte
Who wrote the novel 1984?                          juge -> courte
What is the chemical formula of water?             juge -> courte
How many sides does a triangle have?               juge -> courte

Preference pour la version verbeuse : 0/6 = 0%
Lecture : un taux eleve signe le biais de verbosite ; 'longue' gagne sans information supplementaire.

Lecture du biais de verbosite

Si le juge prefere massivement les versions longues, il recompense le volume, pas l’information - les reponses verbeuses de ce lot n’apportent aucun fait supplementaire, uniquement du remplissage rhetorique. Le lien avec la production est direct : un systeme RAG note par un tel juge apprendra (par selection de prompts, par fine-tuning, par choix de modele) a produire du remplissage. La metrique optimisee devient l’objectif - la loi de Goodhart appliquee au juge LLM.

Le remede tient en une ligne de prompt (“prefer concise answers”) ou en normalisant la longueur avant comparaison ; encore faut-il avoir mesure le biais pour savoir qu’il fallait le corriger.

4.3 Accord entre juges — multi-températures

Un verdict isolé n’est pas une mesure. On évalue le même lot à plusieurs températures (0.0, 0.7, 1.2) et on mesure la proportion d’accord — le pourcentage de paires où les trois verdicts coïncident. Un accord faible signale un juge instable sur ce lot : la mesure elle-même est alors suspecte, et c’est exactement l’information qu’on veut avant de publier une métrique « juge LLM ».

temps = [0.0, 0.7, 1.2]
agreements = 0
matrix = []
for q, a, b in pairs[:10]:
    verdicts_by_t = {t: judge_choice(q, a, b, temperature=t) for t in temps}
    agreed = len(set(verdicts_by_t.values())) == 1
    agreements += agreed
    matrix.append((q[:40], *verdicts_by_t.values(), "accord" if agreed else "divergence"))

print(f"{'question':<42}" + "".join(f"  T={t}" for t in temps))
for row in matrix:
    print(f"{row[0]:<42}" + "".join(f"   {v} " for v in row[1:4]) + f" {row[4]}")
print()
print(f"Proportion d'accord inter-temperatures : {agreements}/{len(matrix)} = {agreements/len(matrix):.0%}")
print("T=0 est le deterministe ; T>0 echantillonne. Un accord < 100% mesure l'instabilite du juge,")

print("pas un 'avis moyen' -- c'est un garde-fou, pas une moyenne a lisser.")
question                                    T=0.0  T=0.7  T=1.2
What is the capital of Peru?                 A    A    A  accord
What is 7 multiplied by 8?                   A    A    A  accord
Who wrote Hamlet?                            A    A    A  accord
What is the chemical symbol of gold?         A    A    A  accord
How many continents are there?               A    A    A  accord
What gas do plants absorb from the atmos     A    A    A  accord
What is the largest planet in the solar      A    A    A  accord
In which city is the Eiffel Tower?           A    A    A  accord
What is the boiling point of water at se     A    A    A  accord
Who painted the Mona Lisa?                   A    A    A  accord

Proportion d'accord inter-temperatures : 10/10 = 100%
T=0 est le deterministe ; T>0 echantillonne. Un accord < 100% mesure l'instabilite du juge,
pas un 'avis moyen' -- c'est un garde-fou, pas une moyenne a lisser.

Lecture de l’accord inter-temperatures

T=0 est le meme juge deterministe relance : sa colonne sert de reference. Les colonnes T=0.7 et T=1.2 echantillonnent la distribution du juge - chaque divergence est un tirage different sur la frontiere de son incertitude. Un accord eleve dit “le juge est sur de lui sur ce lot” ; un accord faible dit “la mesure elle-meme est bruitee” - et aucune moyenne ne rachete une mesure instable : elle la masque.

En pratique : calibrer la temperature du juge sur un lot etiquete (ou la bonne reponse est connue), puis geler. Utiliser un juge a T=1.2 pour noter des candidats en production, c’est mesurer avec un metre en caoutchouc.

4.4 Juge local vs juge API — le même lot, deux moteurs

Le juge self-hosted est reproductible et gratuit ; un juge API est plus capable mais opacité + coût. Comparer leurs verdicts sur le même lot dit quel niveau de juge votre tâche exige réellement : si le 0.5B local suffit à trier vos candidats, l’API n’apporte que sa facture.

api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
    print("OPENAI_API_KEY absente de l'environnement -- comparaison sautee (documente, pas simulable).")
else:
    client_api = OpenAI(api_key=api_key)
    api_model = os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5.6-luna")
    agree = 0
    for q, a, b in pairs[:6]:
        v_local = judge_choice(q, a, b, client=client_local, model=LOCAL_MODEL_ID)
        v_api = judge_choice(q, a, b, client=client_api, model=api_model, temperature=1)
        agree += (v_local == v_api)
        print(f"{q[:46]:<48} local={v_local}  api={v_api}  {'accord' if v_local == v_api else 'divergence'}")
    print()
    print(f"Accord juge local vs juge API : {agree}/6")
    print("Divergences attendues sur les paires les plus subtiles -- le petit juge a ses limites,")
    print("les mesurer fait partie de l'evaluation.")
What is the capital of Peru?                     local=A  api=A  accord
What is 7 multiplied by 8?                       local=A  api=A  accord
Who wrote Hamlet?                                local=A  api=A  accord
What is the chemical symbol of gold?             local=A  api=A  accord
How many continents are there?                   local=A  api=A  accord
What gas do plants absorb from the atmosphere?   local=A  api=A  accord

Accord juge local vs juge API : 6/6
Divergences attendues sur les paires les plus subtiles -- le petit juge a ses limites,
les mesurer fait partie de l'evaluation.

Lecture du duel local vs API

Ce que compare cette cellule n’est pas “quel modele est meilleur” mais quel niveau de juge votre tache exige. Sur des paires trivialement separables (une reponse vraie, une fausse), un juge 0.5B local suffit souvent - et il est gratuit, reproductible, prive. Les divergences, quand elles apparaissent, se concentrent sur les paires subtiles : c’est la que le juge API paie son abonnement.

La regle d’ingenierie qui en decoule : filtrer avec le juge local, arbitrer les cas serres avec le juge API - l’architecture a deux etages des systemes d’evaluation serieux, plutot qu’un API call par paire.


5. Application RAG — fidélité au contexte et rappel des faits

Dans un pipeline RAG, la qualité de la réponse générée se juge sur deux axes distincts du retrieval (recall@k / nDCG mesurent les documents retrouvés, pas ce que le générateur en a fait) :

  • fidélité (faithfulness) : chaque affirmation de la réponse est-elle soutenable par les documents retrievés ? Une affirmation non soutenable = une hallucination, même si elle est vraie dans l’absolu (le système ne peut pas la prouver) ;
  • rappel des faits : les faits importants des documents sont-ils couverts par la réponse ? C’est le rappel ROUGE appliqué au niveau sémantique des faits, pas des n-grammes.

Nous vérifions chaque affirmation par juge local (« cette affirmation est-elle soutenue par le contexte ? ») — le motif exact des frameworks d’évaluation RAG (Ragas & co), implémenté à la main.

corpus_docs = [
    "The Vega telescope, inaugurated in 2019, is located in the Atacama Desert in Chile.",
    "Vega's primary mirror spans 12 meters, making it the largest optical telescope in the southern hemisphere.",
    "The telescope discovered the exoplanet Vega-b in 2021, a gas giant orbiting its star every 40 days.",
    "Vega operates jointly with the ALMA radio array through a fiber link of 300 kilometers.",
]
context = "\n".join(corpus_docs)

answer = ("The Vega telescope, opened in 2019 in the Atacama Desert in Chile, "
          "has a 12-meter mirror, the largest in the southern hemisphere. "
          "In 2021 it discovered the gas giant Vega-b, which orbits its star every 40 days, "
          "and it plans a crewed mission to Vega-b by 2030.")

claims = [
    "The Vega telescope was inaugurated in 2019.",
    "It is located in the Atacama Desert in Chile.",
    "Its primary mirror spans 12 meters, the largest in the southern hemisphere.",
    "It discovered the gas giant Vega-b in 2021.",
    "Vega-b orbits its star every 40 days.",
    "A crewed mission to Vega-b is planned by 2030.",   # <- hallucination : aucun document ne le dit
]

def claim_supported(claim, context):
    system_prompt = ("You are a strict fact-checker. Answer with exactly one word: YES or NO.\n"
                     "Rules:\n"
                     "- Answer YES only if the context ABOVE explicitly states the claim.\n"
                     "- If the context does not mention the claim at all, answer NO, even if the claim is plausible in the real world.\n"
                     "Example: context says nothing about a launch date -> Claim: the telescope launched in 2015 -> NO.")
    r = client_local.chat.completions.create(
        model=LOCAL_MODEL_ID,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Context:\n{context}\n\nClaim: {claim}\n\nIs the claim explicitly stated in the context? YES or NO."},
        ],
        temperature=0.0,
        max_completion_tokens=16,
    )
    return (r.choices[0].message.content or "").strip().upper().startswith("YES")

supported = [claim_supported(c, context) for c in claims]
for c, s in zip(claims, supported):
    print(f"{'SOUTENUE' if s else 'NON-SOUTENUE':<12} {c}")
faithfulness = sum(supported) / len(supported)

facts_in_docs = ["inaugurated 2019", "Atacama Desert Chile", "12-meter mirror largest southern hemisphere",
                 "Vega-b discovered 2021", "orbit every 40 days", "fiber link 300 km with ALMA"]
covered = [claim_supported(f, context) for f in facts_in_docs]  # contre-check : chaque fait est-il bien dans le contexte
print()
print(f"Fidelite de la reponse : {sum(supported)}/{len(supported)} = {faithfulness:.0%}  (1 hallucination piege) ")
print(f"Controle sain : {sum(covered)}/{len(covered)} faits du corpus reconnus soutenus par le contexte.")
SOUTENUE     The Vega telescope was inaugurated in 2019.
SOUTENUE     It is located in the Atacama Desert in Chile.
SOUTENUE     Its primary mirror spans 12 meters, the largest in the southern hemisphere.
SOUTENUE     It discovered the gas giant Vega-b in 2021.
SOUTENUE     Vega-b orbits its star every 40 days.
NON-SOUTENUE A crewed mission to Vega-b is planned by 2030.

Fidelite de la reponse : 5/6 = 83%  (1 hallucination piege) 
Controle sain : 6/6 faits du corpus reconnus soutenus par le contexte.

Lecture de la fidelite mesuree

Le tableau montre la mecanique complete : cinq affirmations soutenables (chacune tracable a une phrase du corpus), une hallucination piege - la mission habitee vers Vega-b, plausible scientifiquement, absente des documents. C’est exactement le defaut que la fidelite doit attraper : une reponse peut etre fluide, bien ecrite, factuellement vraie dans le monde reel - si le contexte ne la soutient pas, un RAG ne doit pas la produire, car l’utilisateur ne peut pas la verifier dans les sources.

Le verdict n’est pas une propriete du modele seul mais du couple (modele, prompt) : avec l’instruction naive (“is the claim supported?”), notre juge 0.5B notait le piege SOUTENU - une fidelite mensongere de 6/6. L’instruction explicite (“YES only if the context ABOVE explicitly states the claim”, example a l’appui) corrige le tir - et un exemple few-shot trop proche du piege fait rebasculer le verdict. La severite d’un fact-checker se calibre sur des cas pieges connus, elle ne se presuppose pas.

La cellule de controle (chaque fait du corpus reconnu soutenu) valide le juge lui-meme : si le fact-checker ratait des faits presents, la mesure de fidelite serait bruitee avant meme de mesurer quoi que ce soit. Verifier l’instrument avant la mesure - meme regle qu’en section 1 avec la comparaison aux libs.

Rappel des faits de la réponse :

La fidélité ne dit pas si la réponse a raté des faits importants. Le rappel se mesure en vérifiant que les faits clés des documents apparaissent dans la réponse. Pour des faits nommés (ALMA, 300 km, hémisphère sud), un contrôle lexical suffit — déterministe et auditable — et il est plus fiable ici que le juge : testé en conditions réelles, le modèle 0.5B répond « oui, la réponse énonce le lien ALMA » alors que la réponse ne contient ni ALMA ni 300 km. Un outil de mesure qui hallucine ne peut pas servir d’instrument ; on ne lui confie que ce qu’il sait faire.

def fact_in_answer(keywords, answer):
    """Controle lexical : le fait est couvert si tous ses mots-cles
    apparaissent dans la reponse. Deterministe, reproductible, verifiable
    a la main - la bonne classe d'outil pour un fait nomme."""
    low = answer.lower()
    return all(k in low for k in keywords)

key_facts = [
    (["alma", "300"], "le lien ALMA, 300 km de fibre"),          # <- dans le corpus, absent de la reponse
    (["gas", "giant"], "Vega-b est une geante gazeuse"),
    (["southern", "hemisphere"], "hemisphere sud"),
]
in_answer = [fact_in_answer(kw, answer) for kw, _ in key_facts]
for (kw, label), s in zip(key_facts, in_answer):
    print(f"{'COUVERT' if s else 'MANQUANT':<10} {label:<38} (mots-cles : {', '.join(kw)})")
recall = sum(in_answer) / len(in_answer)
print()
print(f"Rappel des faits cles : {sum(in_answer)}/{len(in_answer)} = {recall:.0%}")
print("Le lien ALMA (300 km) est dans le corpus mais pas dans la reponse : le rappel le voit,")
print("la fidelite non. Les deux axes sont necessaires et complementaires.")
MANQUANT   le lien ALMA, 300 km de fibre          (mots-cles : alma, 300)
COUVERT    Vega-b est une geante gazeuse          (mots-cles : gas, giant)
COUVERT    hemisphere sud                         (mots-cles : southern, hemisphere)

Rappel des faits cles : 2/3 = 67%
Le lien ALMA (300 km) est dans le corpus mais pas dans la reponse : le rappel le voit,
la fidelite non. Les deux axes sont necessaires et complementaires.

Lecture du rappel des faits

La fidelite etait haute (5/6) et pourtant la reponse oublie le lien ALMA - 300 km de fibre, un fait entier du corpus passe a la trappe. La fidelite sanctionne l’invention ; le rappel sanctionne l’omission. Une reponse “Paris.” a la question “decrivez le telescope Vega” serait parfaitement fidele et parfaitement inutile - c’est le rappel qui le dit.

D’ou le couple minimal d’une evaluation RAG : fidelite x rappel (plus le retrieval en amont, evalue separement par recall@k / nDCG). Un systeme peut tricher sur l’un en sacrifiant l’autre - ne publier qu’un seul des deux scores, c’est cacher la moitie du tableau de bord.


6. Exercices

Les trois exercices suivants reprennent chacun un pilier du notebook et le font passer de “compris en lisant” a “su en ecrivant” : la precision n-gram (exercice 1), la perplexite (exercice 2), et le protocole de juge robuste (exercice 3). Aucun ne demande plus de dix lignes - la difficulte n’est pas le volume de code, c’est de garder le contrat exact (une precision, pas un score ; un verdict coherent, pas deux verdicts juxtaposes). Prenez le temps de les ecrire reellement. Les stubs sont volontairement minimaux et sans erreur volontaire : le notebook s’exécute de bout en bout même non complété ; à vous de remplacer les TODO.

Exercice 1 — BLEU-2 à la main

Implémentez bleu2(candidate, reference) qui renvoie seulement la précision bigramme modifiée (sans BP ni moyenne géométrique), puis vérifiez qu’elle coïncide avec modified_precision(..., 2) de ce notebook.

# === EXERCICE 1 : BLEU-2 (precision bigramme modifiee) ===
# Indice : ngrams() et le clipping min(compte cand, compte ref) sont deja definis plus haut.
# Etape 1 : construire les Counters bigrammes des deux cotes.
# Etape 2 : sommer les min(c, ref[g]).
# Etape 3 : diviser par le total de bigrammes de la candidate.

def bleu2(candidate, reference):
    result = None  # TODO etudiant : renvoyer la precision bigramme modifiee (float)
    return result

_check_c = "the cat is on the mat".split()
_check_r = "the cat sits on the red mat".split()
_expected, _total = modified_precision(_check_c, _check_r, 2)
print(f"Exercice 1 - attendu : {_expected}/{_total} = {_expected/max(_total,1):.4f}")
print(f"Votre bleu2          : {bleu2(_check_c, _check_r)}")
Exercice 1 - attendu : 2/5 = 0.4000
Votre bleu2          : None

Exercice 2 — Perplexité d’une phrase sous votre propre bigramme

Reprenez le modèle bigramme de la section 3 et calculez la perplexité de "the cat ate the fish on the mat" — puis interprétez : est-elle plus haute ou plus basse que celle de la phrase vue à l’entraînement, et pourquoi ?

# === EXERCICE 2 : perplexite d'une phrase nouvelle ===
# Indice : perplexity(sentence) est deja defini ; il suffit de l'appeler et de comparer.
# Etape 1 : mesurer la perplexite de la phrase ci-dessous.
# Etape 2 : la comparer a celle de "the cat sat on the mat" (section 3).
# Etape 3 : ecrire en une phrase POURQUOI l'ecart va dans ce sens.

sentence_exo2 = "the cat ate the fish on the mat"
ppl_exo2 = None  # TODO etudiant : remplacer par perplexity(sentence_exo2)[0]
print(f"Exercice 2 - PPL de votre phrase : {ppl_exo2}")
print("Interpretation : TODO etudiant")
Exercice 2 - PPL de votre phrase : None
Interpretation : TODO etudiant

Exercice 3 — Un protocole de juge anti-biais

Construisez judge_symmetric(question, a, b) : le juge évalue la paire dans les deux ordres à T=0, et la fonction ne tranche que si les deux passes sont cohérentes (le gagnant relatif est le même) ; sinon elle renvoie "INSTABLE". C’est l’agrégation symétrique qui neutralise le biais de position — mesurez sur les 12 paires combien restent instables.

# === EXERCICE 3 : juge symetrique anti-biais de position ===
# Indice : judge_choice(question, a, b) evalue dans l'ordre donne ;
# le gagnant relatif de la 2e passe (b, a) est l'ETIQUETTE INVERSE du retour.
# Etape 1 : deux appels judge_choice (ordre direct, ordre inverse).
# Etape 2 : traduire le 2e verdict en gagnant relatif.
# Etape 3 : retourner le gagnant si coherent, sinon "INSTABLE".

def judge_symmetric(question, a, b):
    result = None  # TODO etudiant
    return result

_instable = sum(1 for q, a, b in pairs if judge_symmetric(q, a, b) == "INSTABLE")
print(f"Exercice 3 - paires INSTABLES sur {len(pairs)} : {_instable}")
Exercice 3 - paires INSTABLES sur 12 : 0

7. Conclusion

Ce qu’il faut retenir :

  • BLEU = précision n-gram avec clipping (ne rien inventer) ; ROUGE = rappel (ne rien rater) ; les deux sont lexicaux et aveugles au sens — deux bonnes réponses peuvent partager zéro n-gramme ;
  • la perplexité relie « prévisible » à la loss d’entraînement : PPL = exp(cross-entropy) ; basse = le modèle hésite entre peu de suites ;
  • le juge LLM mesure le sens mais porte des biais démontrables — position (taux de renversement), verbosité, instabilité en température. Les garde-fous : T=0, rubrique structurée, double ordre, accord mesuré ;
  • en RAG, fidélité (chaque affirmation soutenable) et rappel des faits évaluent la réponse — à ne pas confondre avec recall@k/nDCG qui évaluent le retrieval.

Le réflexe à emporter : aucune métrique seule ne suffit — un score sans son garde-fou (vérification contre une lib, double ordre, accord multi-juges) n’est pas une mesure, c’est un avis.

Sources

  • Papineni et al., BLEU: a Method for Automatic Evaluation of Machine Translation, ACL 2002
  • Lin, ROUGE: A Package for Automatic Evaluation of Summaries, Text Summarization Branches Out 2004
  • Zheng et al., Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena, NeurIPS 2023, arXiv:2306.05685
  • Jukes et al., vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention, arXiv:2309.06180

Navigation : 21_LoRA_FineTuning · 22_Generative_Output_Evaluation (ce notebook) · 5_RAG_Modern · 12_Test_Time_Scaling

Retour au sommet