C’est l’exercice noté central du chapitre. La règle qu’il enseigne :
Avant de confier une tâche à un agent, construisez le jeu d’évaluation qui la mesure.
Vous croyez déjà à cette règle pour les modèles — le chapitre 3 vous a fait évaluer chaque classifieur contre un ensemble de test mis de côté avec sa vérité terrain, et le classement de la classe vous notait sur des données que vous n’aviez jamais vues. Un agent est un modèle muni d’outils. Même règle, même machinerie : définir la tâche précisément, réunir des cas dont on connaît les réponses, noter automatiquement, étudier les échecs. La différence est que c’est vous qui produisez désormais la vérité terrain, et c’est exactement pour cela que des données synthétiques physiquement motivées traversent tout ce cours : mlgeo_synth vous donne un nombre illimité de cas d’évaluation, avec une vérité exacte et gratuite.
Nous traitons un exemple de bout en bout, puis vous répétez la procédure sur une tâche du domaine de votre propre projet.
La tâche cible de l’agent :
Étant donné une série brute de déplacements GNSS journaliers, rapporter la vitesse séculaire en mm/an et signaler tout décalage cosismique.
Une note de plomberie : l’« agent » de ce carnet est une fonction Python qui tient lieu d’agent réel, avec des modes d’échec réalistes, pour que le carnet s’exécute hors ligne et en intégration continue. Le cahier des charges, le jeu d’évaluation, le notateur et la boucle sont exactement ce que vous envelopperiez autour d’un agent en ligne — mais un agent LLM en ligne, échantillonné, ajoute trois exigences que le substitut déterministe n’a pas : valider son schéma de sortie avant de noter (étape 3), exécuter des essais répétés par cas et rapporter des taux de réussite (« Noter sans vérité calculable », ci-dessous), et suivre le coût et la latence. Ce carnet construit les deux premières ; la troisième est de la comptabilité.
Étape 1 : rédiger le cahier des charges de la tâche¶
La plupart des « échecs d’agent » sont des échecs de spécification. Un cahier des charges n’est pas un souhait (« analyse ces données GNSS ») ; c’est un contrat assez précis pour que la réussite ou l’échec soit calculable. Le nôtre, version 1 :
SPEC_V1 = """
TASK: GNSS series characterization, spec v1
INPUT: a DataFrame with columns `date` (daily) and `disp_mm` (one component,
millimeters). No other columns may be used.
OUTPUT: a dict with exactly these keys:
velocity_mm_yr : float — secular velocity
offset : bool — True if a coseismic offset is present
offset_day : int or None — day index of the offset if offset is True
TOLERANCES (a case passes only if ALL hold):
|velocity_mm_yr - truth| <= 1.5 mm/yr
offset flag matches truth
if an offset exists and is flagged: |offset_day - truth| <= 30 days
"""
print(SPEC_V1)
TASK: GNSS series characterization, spec v1
INPUT: a DataFrame with columns `date` (daily) and `disp_mm` (one component,
millimeters). No other columns may be used.
OUTPUT: a dict with exactly these keys:
velocity_mm_yr : float — secular velocity
offset : bool — True if a coseismic offset is present
offset_day : int or None — day index of the offset if offset is True
TOLERANCES (a case passes only if ALL hold):
|velocity_mm_yr - truth| <= 1.5 mm/yr
offset flag matches truth
if an offset exists and is flagged: |offset_day - truth| <= 30 days
Notez ce qui est verrouillé : les colonnes d’entrée (pour que l’agent ne puisse pas jeter un œil aux colonnes de vérité), le schéma de sortie (pour que la notation soit mécanique) et les tolérances numériques (pour que « réussi » ne soit pas une humeur). Notez aussi ce à quoi nous n’avons pas encore pensé : longueur de l’enregistrement, niveau de bruit, taille du décalage. Le jeu d’évaluation va nous en punir, et c’est bien pour cela qu’on le construit.
Étape 2 : produire le jeu d’évaluation¶
Douze cas balayant la difficulté : références propres, signal saisonnier fort, faible rapport signal/bruit, décalages du grand au minuscule, enregistrements courts, et une vitesse négative pour tester les conventions de signe. Chaque cas est un appel au générateur assorti de sa vérité terrain — que nous connaissons puisque c’est nous qui l’avons fixée.
import numpy as np
import pandas as pd
from mlgeo_synth import gnss_series
CASES = [
# (case_id, description, generator parameters)
("C01", "clean, 10 yr, no offset",
dict(n_years=10, velocity_mm_yr=12, eq_day=None, seed=101)),
("C02", "clean, 10 yr, 25 mm offset",
dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=25, postseismic_mm=0, seed=102)),
("C03", "strong seasonal (8 mm), no offset",
dict(n_years=10, velocity_mm_yr=6, annual_mm=8, eq_day=None, seed=103)),
("C04", "strong seasonal, 20 mm offset",
dict(n_years=10, velocity_mm_yr=6, annual_mm=8, eq_day=2000, coseismic_mm=20, postseismic_mm=0, seed=104)),
("C05", "low SNR, no offset",
dict(n_years=10, velocity_mm_yr=3, white_mm=3, flicker_mm=6, eq_day=None, seed=105)),
("C06", "low SNR, 25 mm offset",
dict(n_years=10, velocity_mm_yr=3, white_mm=3, flicker_mm=6, eq_day=1500, coseismic_mm=25, postseismic_mm=0, seed=106)),
("C07", "small offset, 8 mm",
dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=8, postseismic_mm=0, seed=107)),
("C08", "very small offset, 4 mm",
dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=4, postseismic_mm=0, seed=108)),
("C09", "short record, 2 yr, no offset",
dict(n_years=2, velocity_mm_yr=12, eq_day=None, seed=109)),
("C10", "short record, 2 yr, 25 mm offset",
dict(n_years=2, velocity_mm_yr=12, eq_day=365, coseismic_mm=25, postseismic_mm=0, seed=110)),
("C11", "short + strong seasonal, no offset",
dict(n_years=2, velocity_mm_yr=6, annual_mm=8, eq_day=None, seed=111)),
("C12", "negative velocity, offset near end",
dict(n_years=10, velocity_mm_yr=-8, eq_day=3000, coseismic_mm=15, postseismic_mm=0, seed=112)),
]
def load_case(params):
"""Materialize one case: (input the agent sees, ground truth it does not)."""
df = gnss_series(**params)
truth = {
"velocity_mm_yr": params["velocity_mm_yr"],
"offset": params.get("eq_day") is not None,
"offset_day": params.get("eq_day"),
"n_years": params["n_years"],
}
return df[["date", "disp_mm"]], truth # agent gets ONLY raw columns
print(f"{len(CASES)} cases")12 cases
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(9, 5), sharex=False)
for ax, cid in zip(axes.ravel(), ["C01", "C04", "C06", "C08"]):
desc, params = next((d, p) for c, d, p in CASES if c == cid)
df, truth = load_case(params)
ax.plot(np.arange(len(df)), df["disp_mm"], lw=0.4)
if truth["offset"]:
ax.axvline(truth["offset_day"], color="tab:red", ls="--", lw=1)
ax.set_title(f"{cid}: {desc}", fontsize=9)
ax.set_xlabel("day")
ax.set_ylabel("disp (mm)")
fig.suptitle("Four of the twelve eval cases (red dashes: true offset day)")
fig.tight_layout()
Regardez C08 avant de continuer : un décalage de 4 mm sous un bruit coloré de niveau millimétrique est invisible à l’œil. Savoir si l’agent doit être tenu de le trouver est une véritable question scientifique — et écrire le jeu d’évaluation nous a forcés à la poser, avant qu’aucun agent ne tourne. La conception de la couverture en une phrase : balayez chaque bouton qui change la physique du problème (taille du signal, bruit, longueur d’enregistrement, signaux confondants), incluez des cas où la bonne réponse est « il n’y a rien », et incluez au moins un cas à la limite de la détectabilité physique.
Étape 3 : écrire le notateur¶
Mécanique, fondé sur des tolérances, sans appel au jugement au moment de la notation — tout le jugement est passé dans le cahier des charges. Et une propriété non négociable : le notateur doit survivre à une sortie malformée. L’échec le plus courant d’un agent LLM en ligne n’est pas un mauvais nombre ; c’est une sortie qui viole le schéma — une clé manquante, un nombre enrobé de prose, une chaîne là où un flottant est attendu. Un notateur qui lève une KeyError sur une telle sortie ne peut pas consigner l’échec même qu’il lui importe le plus de consigner. score_case valide donc d’abord le résultat contre le schéma de sortie du cahier des charges et convertit toute violation en échec noté, avec son motif, jamais en exception. (La grille de notation, à la fin, attribue les points de solidité du notateur exactement pour ce comportement — l’exemple traité montre désormais à quoi servent ces points.)
RESULT_SCHEMA = {
# key -> types the spec allows
"velocity_mm_yr": (int, float),
"offset": (bool, np.bool_),
"offset_day": (int, np.integer, type(None)),
}
def validate_result(result):
"""Check an agent result against the spec's output schema.
Returns a list of problems; an empty list means the schema holds.
"""
if not isinstance(result, dict):
return [f"not a dict: got {type(result).__name__}"]
problems = [f"missing key '{k}'" for k in RESULT_SCHEMA if k not in result]
for k, types in RESULT_SCHEMA.items():
if k in result and not isinstance(result[k], types):
problems.append(f"'{k}' has type {type(result[k]).__name__}")
return problems
def score_case(result, truth, vel_tol=1.5, day_tol=30):
"""Apply the spec's tolerances to one agent result. Returns per-check booleans.
Malformed output is a scored failure with a reason — never a crash.
"""
problems = validate_result(result)
if problems:
return {"vel_ok": False, "offset_ok": False, "pass": False,
"malformed": "; ".join(problems)}
vel_ok = abs(result["velocity_mm_yr"] - truth["velocity_mm_yr"]) <= vel_tol
flag_ok = bool(result["offset"]) == truth["offset"]
day_ok = True
if truth["offset"] and result["offset"]:
day_ok = abs(result["offset_day"] - truth["offset_day"]) <= day_tol
return {"vel_ok": vel_ok, "offset_ok": flag_ok and day_ok,
"pass": vel_ok and flag_ok and day_ok, "malformed": ""}
def run_eval(agent, vel_tol_fn=lambda truth: 1.5):
"""Run an agent over all cases; return one row per case."""
rows = []
for case_id, desc, params in CASES:
df, truth = load_case(params)
result = agent(df)
s = score_case(result, truth, vel_tol=vel_tol_fn(truth))
valid = not s["malformed"]
rows.append({
"case": case_id, "description": desc,
"true_v": truth["velocity_mm_yr"],
"est_v": round(result["velocity_mm_yr"], 2) if valid else None,
"true_offset": truth["offset"],
"flagged": result["offset"] if valid else None,
**s,
})
return pd.DataFrame(rows).set_index("case")Prouvez que le garde-fou fonctionne avant de lui faire confiance — un résultat délibérément malformé, du genre de ceux qu’un agent en ligne produit lorsqu’il enrobe le nombre de prose et oublie une clé :
bad_result = {"velocity_mm_yr": "about 12 mm/yr", "offset": False} # string; no offset_day
_, truth_c01 = load_case(CASES[0][2])
score_case(bad_result, truth_c01){'vel_ok': False,
'offset_ok': False,
'pass': False,
'malformed': "missing key 'offset_day'; 'velocity_mm_yr' has type str"}Étape 4 : l’exécuter contre l’agent¶
Voici l’agent simulé. Sa stratégie est la stratégie naïve que produit un analyste pressé (humain ou artificiel) : un ajustement en ligne droite pour la vitesse, en ignorant les termes saisonniers et les décalages, et une détection de décalage par recherche du plus grand saut entre moyennes sur 30 jours. Ses modes d’échec sont plantés mais réalistes : vitesse biaisée en présence d’un signal non modélisé, petits décalages manqués, excursions de bruit prises pour des décalages.
def agent_v1(df):
"""Simulated agent, version 1: straight-line velocity + jump detector."""
d = df["disp_mm"].to_numpy()
t_yr = np.arange(len(d)) / 365.25
velocity = np.polyfit(t_yr, d, 1)[0] # ignores seasonal & offsets
best_jump, best_day = 0.0, None
for day in range(45, len(d) - 45, 5): # largest 30-day-mean jump
jump = abs(d[day:day + 30].mean() - d[day - 30:day].mean())
if jump > best_jump:
best_jump, best_day = jump, day
detected = best_jump > 12 # hard-coded threshold, mm
return {"velocity_mm_yr": velocity, "offset": detected,
"offset_day": best_day if detected else None}
res_v1 = run_eval(agent_v1)
res_v1print(f"agent v1: {int(res_v1['pass'].sum())}/12 cases pass "
f"(velocity: {int(res_v1['vel_ok'].sum())}/12, "
f"offset: {int(res_v1['offset_ok'].sum())}/12)")agent v1: 3/12 cases pass (velocity: 6/12, offset: 9/12)
Étape 5 : analyser les échecs, resserrer, réexécuter¶
Trois sur douze. Sans le jeu d’évaluation, cet agent serait passé en production : ses réponses sur une série isolée ont l’air raisonnables. Lisez les échecs par groupes — le tableau est le diagnostic :
- C02, C04, C06, C10 — vitesse fausse dès qu’un décalage est présent. L’ajustement en ligne droite absorbe la marche non modélisée dans la pente. Sur l’enregistrement de 2 ans (C10), le biais atteint ~18 mm/an. Mode d’échec corrélé : les deux sorties ne sont pas indépendantes, un terme de modèle manquant corrompt donc un nombre rapporté.
- C07, C08 — petits décalages manqués. Le seuil codé en dur à 12 mm ne peut pas voir des marches de 8 ou 4 mm. Le cahier des charges n’a jamais dit quel devait être le décalage minimal détectable ; le jeu d’évaluation a simplement rendu ce trou visible.
- C05 — fausse alarme. Le bruit de scintillement (flicker) erre assez pour que certaines moyennes sur 30 jours diffèrent de plus de 12 mm sans le moindre séisme. Les cas « pas de décalage » méritent leur place dans un jeu d’évaluation : un détecteur n’a de sens que s’il sait rester silencieux.
- C09, C11 — vitesse fausse sur les enregistrements courts, même sans décalage. Deux ans de signal saisonnier plus du bruit coloré viennent à bout d’un ajustement en ligne droite.
L’analyse des échecs dicte la correction, et c’est une correction du cahier des charges autant que de l’agent. La version 2 ajoute une clause de méthode obligatoire — « estimer la vitesse par moindres carrés avec des termes annuel et semi-annuel et, si un décalage est signalé, une fonction échelon au jour détecté » — le modèle physique du chapitre 2. L’agent amélioré l’implémente :
def agent_v2(df):
"""Simulated agent, version 2: fits trend + seasonal + step (spec v2)."""
d = df["disp_mm"].to_numpy()
n = len(d)
t = np.arange(n)
t_yr = t / 365.25
best_jump, best_day = 0.0, None
for day in range(45, n - 45, 5):
jump = abs(d[day:day + 30].mean() - d[day - 30:day].mean())
if jump > best_jump:
best_jump, best_day = jump, day
G = np.column_stack([
np.ones(n), t_yr,
np.sin(2 * np.pi * t_yr), np.cos(2 * np.pi * t_yr),
np.sin(4 * np.pi * t_yr), np.cos(4 * np.pi * t_yr),
(t >= best_day).astype(float),
])
coef, *_ = np.linalg.lstsq(G, d, rcond=None)
detected = abs(coef[-1]) > 6 # decide on the FITTED step amplitude
if not detected: # refit without the step term
coef, *_ = np.linalg.lstsq(G[:, :-1], d, rcond=None)
return {"velocity_mm_yr": coef[1], "offset": detected,
"offset_day": best_day if detected else None}
res_v2 = run_eval(agent_v2)
print(f"agent v2: {int(res_v2['pass'].sum())}/12 cases pass")
res_v2[~res_v2["pass"]]agent v2: 10/12 cases pass
Dix sur douze, et tous les échecs de physique ont disparu : les décalages jusqu’à 4 mm sont trouvés (décider sur l’amplitude ajustée de la marche bat le seuillage des sauts bruts), la fausse alarme à faible rapport signal/bruit disparaît, les vitesses sont justes partout où le modèle est adéquat.
Les deux survivants — C09 et C10 — sont tous deux des enregistrements de 2 ans, et leurs écarts de vitesse (~1,7 à 2 mm/an) ne sont pas la faute de l’agent. L’incertitude de vitesse due au bruit coloré GNSS varie à peu près en 1/T : deux ans de bruit de scintillement ne peuvent pas livrer ±1,5 mm/an, quel qu’en soit l’analyste. Notre cahier des charges exigeait ce que les données ne peuvent pas donner. C’est la deuxième chose que les jeux d’évaluation attrapent : les cahiers des charges déraisonnables.
Jusqu’où relâcher la tolérance, alors ? Pas jusqu’à ce que les échecs disparaissent — ce serait noter sur une courbe tracée après avoir vu la copie. La tolérance est une propriété de l’estimateur sous la physique, et comme nous possédons le générateur, nous pouvons la mesurer : exécutez l’estimateur sur de nombreuses réalisations fraîches sans décalage, à chaque longueur d’enregistrement, et regardez la distribution de son erreur.
def velocity_error_spread(n_years, n_trials=50, truth_v=12.0):
"""|velocity error| of agent_v2 over fresh offset-free realizations."""
errs = []
for s in range(1000, 1000 + n_trials): # seeds disjoint from the eval set
df = gnss_series(n_years=n_years, velocity_mm_yr=truth_v, eq_day=None, seed=s)
est = agent_v2(df[["date", "disp_mm"]])["velocity_mm_yr"]
errs.append(abs(est - truth_v))
return np.array(errs)
for T in (10, 2):
e = velocity_error_spread(T)
print(f"{T:>2}-yr records, 50 seeds: median |error| {np.median(e):.2f} mm/yr, "
f"95th percentile {np.quantile(e, 0.95):.2f} mm/yr")10-yr records, 50 seeds: median |error| 0.09 mm/yr, 95th percentile 0.28 mm/yr
2-yr records, 50 seeds: median |error| 0.78 mm/yr, 95th percentile 1.85 mm/yr
La distribution répond à la question. Enregistrements de dix ans : erreur au 95e centile proche de 0,3 mm/an, les ±1,5 mm/an d’origine sont donc confortables. Enregistrements de deux ans : le 95e centile se situe vers 1,9 mm/an — les ±1,5 n’ont jamais été atteignables, et une tolérance de ±3 mm/an (environ 1,5 fois le 95e centile mesuré, avec de la marge parce que 50 essais ne fixent la queue que lâchement et que nous n’avons échantillonné qu’une seule vitesse vraie) est ce que la physique autorise. La version 3 du cahier des charges inscrit cette mesure dans le contrat. Règle générale : dérivez les tolérances de réalisations de l’estimateur, pas d’une première intuition — avec un générateur synthétique, la dérivation coûte cinq lignes.
def vel_tol_v3(truth):
# 1.5 covers the measured 10-yr error spread with wide margin;
# 3.0 ~ 1.5x the measured 95th-percentile error on 2-yr records.
return 1.5 if truth["n_years"] >= 5 else 3.0
res_v3 = run_eval(agent_v2, vel_tol_fn=vel_tol_v3)
summary = pd.DataFrame({
"agent v1, spec v1": res_v1["pass"],
"agent v2, spec v2": res_v2["pass"],
"agent v2, spec v3": res_v3["pass"],
})
print(f"final: {int(res_v3['pass'].sum())}/12")
summaryfinal: 12/12
Douze sur douze — et, plus important, une piste d’audit : nous savons quelles capacités ont été testées, quelles tolérances la physique autorise, et ce que l’agent ne savait pas faire avant que sa méthode ne change. Ce tableau est ce que devrait signifier « je fais confiance à cet agent pour la caractérisation GNSS ».
Voici la boucle pilotée par l’évaluation :
- cahier des charges → 2. jeu d’évaluation avec vérité terrain → 3. notateur → 4. exécution → 5. analyse des échecs → resserrer le cahier des charges ou corriger l’agent → retour en 4.
C’est la même boucle que le développement de modèles au chapitre 3, le jeu d’évaluation jouant le rôle de l’ensemble de test caché. Deux mises en garde se transfèrent avec elle. D’abord, un jeu d’évaluation sur lequel vous itérez est un ensemble de validation ; si l’enjeu est élevé, gardez de côté des cas frais (de nouvelles graines aléatoires !) pour un contrôle final, exactement comme le classement gardait ses données de test. Ensuite, l’évaluation ne couvre que ce que vous y avez mis — notre agent est validé pour des séries à une composante comportant au plus un décalage, et prétendre davantage serait la surenchère que le chapitre 7.2 vous apprend à consigner honnêtement.
Noter sans vérité calculable¶
Tout ce qui précède notait contre une vérité numérique exacte : abs(est - truth) <= tol tranche chaque cas. Beaucoup de tâches que vous déléguerez réellement n’ont pas un tel nombre. L’exemple le plus net est celui vers lequel ce cours tend : l’agent de relecture avant soumission que vous construirez à partir de votre propre grille de qualité, et dont le travail est de critiquer un article avant que vous ne le soumettiez. Une relecture n’a pas de velocity_mm_yr. Sa sortie est un jugement, et « cette relecture est-elle bonne ? » est elle-même un jugement.
La machinerie d’évaluation survit, moyennant trois substitutions.
1. Le notateur devient une grille. Décomposez « cette relecture est-elle bonne » en contrôles binaires qu’un lecteur peut trancher sans vous demander : nomme le défaut de fuite de données, chaque critique cite une ligne, une figure ou un nombre précis, propose une correction concrète pour chaque défaut, ne loue pas un défaut. Vous avez construit exactement ce type de grille en 6.2, partie (c). Et l’astuce de la vérité plantée, tirée de ce carnet, s’applique toujours : donnez à l’agent de relecture des articles comportant des défauts connus et plantés — un bogue de fuite de données, une citation fabriquée, un seuil réglé sur le jeu de test — et « a trouvé le défaut planté » redevient un contrôle calculable. La vérité terrain d’une tâche subjective se construit par ingénierie ; elle ne se trouve pas.
2. Vous devez mesurer si la grille peut être appliquée de façon cohérente. Une grille ne fonctionne comme notateur que si deux évaluateurs qui l’appliquent à la même sortie aboutissent aux mêmes verdicts. Cela se mesure : faites noter la même sortie par deux évaluateurs (l’échange avec un binôme de la partie (c) de 6.2 produit exactement ces données) et calculez leur accord. Le pourcentage d’accord brut ne suffit pas — si la plupart des contrôles passent, deux évaluateurs s’accordent souvent par hasard — rapportez donc le kappa de Cohen, l’accord corrigé du hasard. Il tient en cinq lignes :
# Two raters apply the same 12-check rubric to one AI-written review
# (1 = criterion met). The 6.2 part (c) partner swap yields exactly this data.
rater_1 = np.array([1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1])
rater_2 = np.array([1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1])
def cohens_kappa(a, b):
"""Two-rater agreement corrected for chance: (p_o - p_e) / (1 - p_e)."""
a, b = np.asarray(a), np.asarray(b)
p_o = (a == b).mean() # observed
p_e = a.mean() * b.mean() + (1 - a.mean()) * (1 - b.mean()) # by chance
return (p_o - p_e) / (1 - p_e)
print(f"percent agreement: {100 * (rater_1 == rater_2).mean():.0f}% "
f"kappa: {cohens_kappa(rater_1, rater_2):.2f}")
# Why percent agreement alone misleads: two lenient raters who pass almost
# everything agree constantly -- by chance.
lenient_1 = np.array([1] * 11 + [0])
lenient_2 = np.array([1] * 12)
print(f"lenient raters: {100 * (lenient_1 == lenient_2).mean():.0f}% "
f"kappa: {cohens_kappa(lenient_1, lenient_2):.2f}")percent agreement: 83% kappa: 0.63
lenient raters: 92% kappa: 0.00
Rapportez les deux nombres. La première paire s’accorde sur 83 % des contrôles et le kappa vaut 0,63 — une cohérence réelle mais imparfaite. La paire indulgente s’accorde sur 92 % des contrôles et le kappa vaut 0,00 : cet accord est intégralement ce que deux béni-oui-oui produisent par hasard. Un kappa faible est un défaut de la grille avant d’être un défaut des évaluateurs : chaque désaccord marque un critère dont deux lecteurs ont résolu la formulation différemment. Réécrivez ce critère, renotez, remesurez. Une grille que deux humains ne peuvent pas appliquer de façon cohérente n’est pas prête à être confiée à un juge LLM.
Quand le second évaluateur est un modèle (comme en 6.2 partie (c), et comme le sera la notation de votre propre agent de relecture), ajoutez les contrôles des biais de juge que vous y avez mesurés, appliqués au moment de la notation :
- Ordre. Notez la paire dans l’ordre (A, B) puis dans l’ordre (B, A) ; un verdict qui bascule avec la position est un biais de position, pas du signal.
- Verbosité. Notez chaque contrôle de la grille indépendamment au lieu de demander une impression d’ensemble — la longueur gonfle bien plus les notes globales que les réponses contrôle par contrôle.
- Aveugle. Ne dites pas au juge quelle sortie vient de votre propre agent, et retirez tout préambule auto-identifiant avant de noter.
3. Les verdicts uniques deviennent des taux de réussite. agent_v2 renvoie le même dictionnaire à chaque exécution ; un LLM échantillonné, non. Exécutez un agent de relecture en ligne cinq fois sur le même article : il attrapera un défaut planté à certaines exécutions et le ratera à d’autres. L’unité de mesure par cas passe donc de réussite/échec à un taux de réussite assorti de son incertitude d’échantillonnage. Ci-dessous, des résultats enregistrés d’un agent de relecture exécuté N = 5 fois par cas sur six courtes analyses à défauts plantés — les transcriptions ont été enregistrées une fois puis notées à la grille, cette cellule s’exécute donc hors ligne et en intégration continue comme tout le reste :
# Rubric-scored outcome of each recorded run: 1 = the review named the planted
# defect (for P03, correctly reported that nothing was planted).
recorded_runs = {
"P01 leakage: scaler fit before split": [1, 1, 1, 1, 1],
"P02 threshold tuned on the test split": [1, 1, 0, 1, 1],
"P03 no planted flaw (negative case)": [1, 1, 1, 1, 1],
"P04 single seed, no spread reported": [0, 1, 0, 1, 0],
"P05 axis units off by 10^3 in figure": [0, 0, 1, 0, 0],
"P06 fabricated citation in related work": [1, 0, 1, 1, 0],
}
rows = []
for case, outcomes in recorded_runs.items():
p = np.mean(outcomes)
se = np.sqrt(p * (1 - p) / len(outcomes)) # binomial standard error
rows.append({"case": case, "runs": "".join(map(str, outcomes)),
"pass_rate": p, "std_err": round(se, 2)})
pd.DataFrame(rows).set_index("case")Lisez P04 : une évaluation à une seule exécution l’aurait déclaré réussi ou échoué à pile ou face — exécutez une fois et livrez, et votre conclusion sur l’agent dépend du tirage. Et les erreurs types disent ce que signifie une différence : à N = 5, un taux voisin de 0,5 porte ±0,22, une version d’agent qui obtient 3/5 là où une autre obtenait 2/5 sur un cas n’est donc pas une preuve d’amélioration. Trois conséquences pour l’évaluation de votre point d’orgue : rapportez des taux, pas des verdicts ; fixez N dans le cahier des charges avant d’exécuter ; et exigez un écart plus grand que l’erreur type avant de croire qu’un changement a aidé (ou augmentez N jusqu’à ce que les barres d’erreur se séparent).
Prises ensemble — contrôles de grille sur des défauts plantés, kappa entre deux évaluateurs sur la grille elle-même, contrôles d’ordre, de verbosité et d’aveugle, taux de réussite sur N exécutions — voilà la machinerie de notation complète de l’agent de relecture que vous construirez dans l’arc de lecture. Elle note des articles au lieu de vitesses, et cela ne l’exempte en rien d’être évaluée.
Facultatif : la même évaluation contre un modèle en ligne¶
Tout ce qui est noté dans ce chapitre s’exécute hors ligne : l’agent simulé et les transcriptions enregistrées sont la référence notée, et l’intégration continue les exécute sans réseau. Si vous avez le matériel et l’envie, le même harnais tourne sans modification contre un vrai modèle à poids ouverts — OLMo 2, le modèle entièrement ouvert d’Ai2 (poids, données d’entraînement et code publiés) — servi localement par Ollama :
ollama pull olmo2 # 7B default: ~4.5 GB disk; ~8 GB RAM to run; CPU works, slowly
export MLGEO_LIVE_EVAL=1 # opt in, then launch Jupyter from the same shellOllama expose un point d’accès compatible OpenAI à l’adresse http://localhost:11434/v1 : l’« agent » ci-dessous se réduit donc à un appel HTTP. Nous donnons au modèle les moyennes sur 30 jours de la série (environ 120 nombres pour un enregistrement de 10 ans) — soit délibérément la situation du mauvais calculateur de 6.1 : un moteur de texte face à un problème de régression. Attendez-vous à des violations de schéma et à des vitesses fausses. C’est là le point pédagogique : le garde-fou de schéma consigne les sorties malformées au lieu de planter, les tolérances jugent les nombres, et à température d’échantillonnage non nulle il vous faudrait la machinerie des essais répétés de la section précédente. Un agent en ligne compétent écrirait et exécuterait du code sur la série brute ; câbler cela est un exercice à l’échelle d’un projet, pas d’une cellule de carnet.
La cellule n’exécute l’évaluation en ligne que si vous avez donné votre accord explicite avec MLGEO_LIVE_EVAL=1 et qu’un serveur Ollama local dispose d’un modèle olmo2 téléchargé ; sinon elle affiche une note et passe. Elle ne peut pas faire échouer une compilation : sans accord explicite, aucun appel réseau, et même une exécution acceptée qui échoue se dégrade en un message. Rien de tout cela n’est noté, et aucun devoir ne l’exige.
import json
import os
import urllib.request
OLLAMA = "http://localhost:11434"
def ollama_model(prefix="olmo2"):
"""Exact tag of a pulled model matching `prefix` (e.g. 'olmo2:7b'), or None."""
try:
with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=2) as r:
tags = json.load(r)
return next((m["name"] for m in tags.get("models", [])
if m["name"].startswith(prefix)), None)
except Exception:
return None
def ask_olmo(prompt, model):
body = json.dumps({"model": model, "temperature": 0,
"messages": [{"role": "user", "content": prompt}]}).encode()
req = urllib.request.Request(f"{OLLAMA}/v1/chat/completions", data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=600) as r:
return json.load(r)["choices"][0]["message"]["content"]
def make_agent_olmo(model):
def agent_olmo(df):
"""Live agent: the spec plus 30-day means of the series, JSON answer requested."""
means = df["disp_mm"].groupby(np.arange(len(df)) // 30).mean().round(1)
prompt = (SPEC_V1
+ "\nDATA: consecutive 30-day means of disp_mm, in mm "
"(bin i covers days 30*i to 30*i + 29):\n"
+ ", ".join(str(v) for v in means)
+ "\nAnswer with ONLY the JSON object described under OUTPUT. "
"offset_day must be a day index, not a bin index.")
text = ask_olmo(prompt, model)
try:
return json.loads(text[text.index("{"): text.rindex("}") + 1])
except Exception:
return {"unparseable_text": text} # the schema guard scores this
return agent_olmo
# Opt in explicitly: export MLGEO_LIVE_EVAL=1 before launching Jupyter.
# The env-var gate keeps book builds fast and deterministic even on machines
# where an Ollama server happens to be running.
live_model = ollama_model() if os.environ.get("MLGEO_LIVE_EVAL") == "1" else None
if live_model is None:
print("Live run disabled (set MLGEO_LIVE_EVAL=1 with a local Ollama server "
"and `ollama pull olmo2`).")
print("The offline simulated/recorded path above is the graded baseline.")
else:
try:
res_live = run_eval(make_agent_olmo(live_model), vel_tol_fn=vel_tol_v3)
print(f"{live_model} live: {int(res_live['pass'].sum())}/12 cases pass")
display(res_live)
except Exception as e: # a live server that misbehaves must not kill the notebook
print(f"Live run against {live_model} failed ({type(e).__name__}: {e}) — skipping.")Live run disabled (set MLGEO_LIVE_EVAL=1 with a local Ollama server and `ollama pull olmo2`).
The offline simulated/recorded path above is the graded baseline.
Devoir : un jeu d’évaluation pour le domaine de votre projet¶
Répétez les étapes 1 à 5 pour une tâche d’agent issue du domaine de votre projet. Vous choisissez la tâche ; ce doit être une tâche que vous délégueriez réellement. Exemples pour calibrer l’ampleur : « pointer l’arrivée de l’onde P à ±0,1 s près » (seismogram_dataset), « classer la lithologie à partir d’une ligne de géochimie et s’abstenir en cas d’incertitude » (geochem_table), « estimer la valeur b à partir d’un catalogue de magnitudes » (gutenberg_richter_magnitudes), « signaler les événements rares dans cette série de capteur » (inject_rare_events).
Livrables, dans un seul carnet :
- Cahier des charges de la tâche — entrées, schéma de sortie, tolérances et toute contrainte de méthode exigée. Écrit en premier, versionné s’il a changé. Tolérances dérivées de réalisations là où vous possédez le générateur, non devinées.
- Jeu d’évaluation — au moins 10 cas issus d’un générateur
mlgeo_synth(ou de votre propre générateur avec vérité terrain documentée), balayant la difficulté selon au moins trois axes, comprenant des cas négatifs (« il n’y a rien ») et au moins un cas à la limite de la détectabilité. - Notateur — mécanique, s’exécutant en un appel de fonction, et renvoyant un échec noté (jamais une exception) sur une sortie d’agent manquante ou malformée, comme à l’étape 3. Fondé sur des tolérances là où la vérité est calculable ; si la sortie de votre tâche est un jugement, utilisez plutôt des contrôles de grille sur des défauts plantés, et rapportez l’accord entre deux évaluateurs (pourcentage + kappa) sur au moins un sous-ensemble (« Noter sans vérité calculable »).
- Exécution + analyse des échecs — exécutez votre agent (réel ou simulé), regroupez les échecs et diagnostiquez chaque groupe : limite de l’agent, défaut du cahier des charges, ou impossibilité physique ?
- Rédaction — une demi-page : ce que le jeu d’évaluation couvre, ce qu’il ne couvre délibérément pas, et ce que vous feriez désormais confiance à l’agent de faire sans supervision.
Grille de notation (100 points) :
| Critère | Points | Ce qui les rapporte |
|---|---|---|
| Clarté du cahier des charges | 25 | Schéma de sortie et tolérances calculables sans vous demander ; contraintes justifiées |
| Couverture du jeu d’évaluation | 25 | Les axes de difficulté couvrent la physique ; négatifs inclus ; cas à la limite de détectabilité présent ; vérité terrain exacte |
| Solidité du notateur | 20 | Fonction pure de (résultat, vérité, cahier des charges) ; aucun jugement a posteriori ; gère une sortie d’agent manquante ou malformée |
| Analyse des échecs | 20 | Échecs regroupés et diagnostiqués, non listés ; chaque groupe rattaché à l’agent, au cahier des charges ou à la physique |
| Rédaction | 10 | Énoncé honnête du périmètre ; le lecteur sait exactement ce qui reste non testé |
Chaque ligne de la grille est démontrée dans l’exemple traité ci-dessus — la gestion des sorties malformées que demande la solidité du notateur, c’est le validate_result de l’étape 3, et la justification de tolérance que demande la clarté du cahier des charges, c’est la mesure sur 50 réalisations qui précède la version 3.
Un avertissement tiré de l’expérience : la façon la plus courante de perdre des points est un jeu d’évaluation que votre agent réussit 10 fois sur 10 du premier coup. Ce n’est pas un bon agent ; c’est un examen facile.