Salta al contenuto principale

98 - Valutare un sistema generativo: il golden set, il giudice automatico e i suoi pregiudizi

Quanti casi servono per distinguere due sistemi, come si tara un giudice con il kappa e come si cercano i suoi pregiudizi, con giudici simulati e dichiarati.

Cosa ne pensi di questo articolo?

98 - Valutare un sistema generativo: il golden set, il giudice automatico e i suoi pregiudizi
Machine Learning Engineerfedericocalo.dev

In Breve

Quanti casi servono per distinguere due sistemi, come si tara un giudice con il kappa e come si cercano i suoi pregiudizi, con giudici simulati e dichiarati.

Hai cambiato il prompt di un sistema che risponde a domande sui tuoi documenti, e a occhio le risposte ti sembrano migliori. Prima di dirlo a qualcuno dovresti poterlo dimostrare, e qui nascono tre problemi che quasi nessuno separa: quante domande servono per vedere una differenza di cinque punti, chi decide che una risposta riformulata è giusta, e quanto puoi fidarti di un giudice automatico che lo decide al posto tuo. Un giudice che sbaglia sempre nella stessa direzione non si limita a sbagliare: ti consegna con sicurezza un ordinamento sbagliato.

La lezione sul RAG ha costruito un sistema che recupera e cita; questa chiede come si fa a dire che è migliorato. Riprendo il corpus e le 18 domande di quella lezione, le allargo a un golden set di 36 casi, calcolo quali differenze posso leggere, confronto tre metriche automatiche con le mie etichette, misuro l'accordo di giudici diversi con il kappa di Cohen, e provo a cercare i tre pregiudizi che la letteratura attribuisce ai giudici automatici. Chiudo con le regressioni fra due versioni di uno stesso sistema, che è l'uso quotidiano di tutto il resto.

Come sono scritti gli esempi, e che cosa possono dimostrare. In questa lezione non gira nessun modello e non si chiama nessun servizio. I tre sistemi da valutare sono un estrattore a copione (modello_finto, lo stesso della lezione sul RAG), una sua versione con la soglia di astensione più alta e una tabella di risposte scritte da me; i giudici sono programmi che sbagliano con probabilità che fisso io, tranne uno, un giudice a regole che è un programma vero. Il seme è fisso e l'intero programma, composto dai blocchi di questa pagina nell'ordine in cui compaiono, è stato eseguito due volte con uscita identica. Versioni: Python 3.14.4, NumPy 2.3.5, SciPy 1.16.3.

Questo cambia la natura di ciò che dimostro. Con giudici simulati posso mostrarti il metodo: come si dimensiona un golden set, come si calcola un intervallo, come si riconosce un giudice pigro dal kappa, come si scopre che un pregiudizio c'è o non c'è. Non posso dirti quanto sia grande il pregiudizio di posizione di un modello vero, perché il pregiudizio che trovo è quello che ho scritto nei parametri. Dove un risultato vale solo con un modello vero lo attribuisco ai lavori che lo hanno misurato, e te lo segnalo; dove serve un modello, il codice è uno schema da adattare e non è stato eseguito.

Che cosa è reale e che cosa è finto in questa lezione

  • Corpus e domande: reali. I docstring della libreria standard, estratti con inspect, e le 18 domande della lezione sul RAG, con l'ancora letterale che contiene la risposta.
  • Sistemi A e A2: programmi veri e deterministici. Prendono la frase del contesto che condivide più parole con la domanda e si astengono sotto una soglia (2 per A, 3 per A2). Non sono modelli.
  • Sistema C: una tabella di 20 risposte scritte e giudicate da me. Serve a produrre ciò che un estrattore non produce: risposte giuste con parole diverse dal riferimento, e qualche risposta plausibile e sbagliata.
  • Giudici G1, G2, G3: simulati, con tassi di errore fissati nel codice. Il giudice a regole (F1 sui token con soglia) è invece un programma reale.
  • Pregiudizi: parametri miei. Misurarli significa verificare che il protocollo li ritrova.

Leggi l'elenco come una mappa di fiducia. Tutto ciò che riguarda i conteggi, gli intervalli, i test e le metriche lessicali è calcolo vero su dati veri, e lo rifai identico. Tutto ciò che riguarda quanto sbaglia un giudice è un'ipotesi che ho scritto io: serve per provare gli strumenti di misura su un caso dove la risposta giusta è nota, che è anche il modo in cui si collaudano i veri.

Il golden set: 36 casi costruiti dal corpus

Un golden set è un insieme di casi in cui la risposta giusta la decidi tu: domanda, contesto e il riferimento che contiene la risposta. Non puoi comprarlo né generarlo con un modello, perché se lo generi misuri la concordanza fra due modelli e la chiami accuratezza. Il corpus è quello della lezione sul RAG: diciotto moduli della libreria standard, ognuno ridotto ai suoi docstring.

import collections, hashlib, importlib, inspect, math, re, statistics, time
import numpy as np

MODULI = ["statistics", "bisect", "heapq", "itertools", "functools", "difflib",
          "textwrap", "tempfile", "uuid", "base64", "secrets", "gzip", "math",
          "glob", "zlib", "operator", "fractions", "fnmatch"]

def normalizza(s):
    return " ".join(s.split())

def costruisci_corpus():
    documenti, visti = [], set()
    for nome in MODULI:
        m = importlib.import_module(nome)
        parti = [f"MODULE {nome}"]
        if inspect.getdoc(m):
            parti.append(inspect.getdoc(m).strip())
        for attr, obj in sorted(vars(m).items()):
            if attr.startswith("_"):
                continue
            if not (inspect.isfunction(obj) or inspect.isclass(obj) or inspect.isbuiltin(obj)):
                continue
            doc = inspect.getdoc(obj)
            if not doc or len(doc.split()) < 20:
                continue
            casa = obj.__module__.lstrip("_")               # il modulo che la definisce
            if casa != nome and casa in MODULI:             # ri-esportata da un modulo che
                continue                                    # sta nel corpus: la tiene il suo
            impronta = (obj.__module__, obj.__qualname__)   # proprietario; poi una volta sola
            if impronta in visti:
                continue
            visti.add(impronta)
            parti.append(f"## {nome}.{attr}\n\n{doc.strip()}")
        documenti.append({"id": nome, "testo": "\n\n".join(parti), "stato": "corrente"})
    return documenti

La funzione tiene ogni oggetto una volta sola, nel modulo che lo definisce, e scarta i docstring sotto le venti parole: una riga come «Return the sum» non può contenere una risposta che valga la pena di verificare. Il filtro sul modulo di origine evita che functools e statistics si contendano la stessa funzione ri-esportata.

# (domanda, modulo, ancora): l'ancora e' la stringa letterale che contiene la risposta
GOLDEN = [
    ("What is the default cutoff of get_close_matches?", "difflib",
     "Optional arg cutoff (default 0.6)"),
    ("Which position does bisect_left return when x is already in the list?", "bisect",
     "insert just before the leftmost x already there"),
    ("Where does insort_right put an item that is already present?", "bisect",
     "insert it to the right of the rightmost x"),
    ("How does heappushpop compare with heappush followed by heappop?", "heapq",
     "runs more efficiently than heappush() followed by a separate call to heappop()"),
    ("What does fmean do when the dataset is empty?", "statistics",
     "If the input dataset is empty, it raises a StatisticsError."),
    ("What is the average speed from harmonic_mean for 40 km/hr and 60 km/hr over equal distances?", "statistics",
     "harmonic_mean([40, 60]) 48.0"),
    ("What does median_low return with an even number of data points?", "statistics",
     "the smaller of the two middle values is returned"),
    ("How does textwrap shorten treat whitespace in the text?", "textwrap",
     "The text first has its whitespace collapsed."),
    ("Who can read the directory created by mkdtemp?", "tempfile",
     "readable, writable, and searchable only by the creating user"),
    ("Is a NamedTemporaryFile deleted automatically by default?", "tempfile",
     "whether the file is automatically deleted (default True)"),
    ("Why use compare_digest instead of comparing secrets with ==?", "secrets",
     "designed to prevent timing analysis"),
    ("What happens to the last batch of itertools batched when the input runs out?", "itertools",
     "The last batch may be shorter than n."),
    ("What does lru_cache do when maxsize is None?", "functools",
     "the LRU features are disabled and the cache can grow without bound"),
    ("Which characters replace plus and slash in urlsafe_b64encode?", "base64",
     "uses '-' instead of '+' and '_' instead of '/'"),
    ("Does the star wildcard of glob match filenames that start with a dot?", "glob",
     "filenames starting with a dot are special cases that are not matched by '*' and '?'"),
    ("Does gzip compress store the modification time of the data?", "gzip",
     "The modification time is set to 0 by default, for reproducibility."),
    ("What does math comb return when k is greater than n?", "math",
     "evaluates to zero when k > n"),
    ("What rational number does Fraction(8, 6) produce?", "fractions",
     "Fraction(8, 6) will produce a rational number equivalent to 4/3"),
]
DOMANDE = [g[0] for g in GOLDEN]
ANCORE = [g[2] for g in GOLDEN]

Il riferimento di ogni domanda è una stringa letterale presa dal docstring, non una parafrasi, e questo permette di verificare il golden set da solo.

documenti = costruisci_corpus()
by_id = {d["id"]: d for d in documenti}
for domanda, modulo, ancora in GOLDEN:
    assert normalizza(ancora) in normalizza(by_id[modulo]["testo"]), (modulo, ancora)
print(f"documenti: {len(documenti)} | domande del golden set: {len(GOLDEN)}")

L'assert è la parte più utile del blocco: se una versione futura di Python cambia un docstring, il golden set si rompe a voce alta invece di diventare silenziosamente sbagliato. Il golden set è un file versionato che dipende dalla versione di Python con cui lo hai costruito, e conviene che lo dichiari.

Da ogni domanda ricavo due casi. Il primo, della famiglia R, ha il contesto che contiene la risposta: una finestra di 80 parole del docstring giusto. Il secondo, della famiglia N, ha la stessa domanda accoppiata al contesto di un altro modulo, e la risposta giusta è l'astensione, cioè la stringa esatta NOT IN CONTEXT. Per il caso N non prendo un contesto a caso: prendo il chunk altrui che condivide più parole con la domanda, perché è il trabocchetto più duro. Un sistema che si astiene solo quando il contesto è palesemente estraneo non ha imparato ad astenersi.

STOP = set("a an and are as at be by for from if in into is it its of on or that the this to when with "
           "what which who how does do not can than then there their was were will".split())

def parole(testo):
    testo = re.sub(r"([a-z])([A-Z])", r"\1 \2", testo)
    return [w for w in re.findall(r"[a-z0-9]+", testo.lower()) if w not in STOP]

def finestra_fissa(testo, dim, sovrapposizione):
    parole_ = testo.split()
    passo = max(1, dim - sovrapposizione)
    return [" ".join(parole_[i:i + dim]) for i in range(0, len(parole_), passo) if parole_[i:i + dim]] or [testo]

def frasi(testo):
    return [f for f in re.split(r"(?<=[.!?])\s+", normalizza(testo)) if f]

def modello_finto(domanda, span, soglia=2):
    """Estrattore a copione: NON e' un modello. Restituisce (risposta, frase scelta)."""
    qt = set(parole(domanda))
    punti, scelta = 0, ""
    for frase in frasi(span):
        p = len(qt & set(parole(frase)))
        if p > punti:
            punti, scelta = p, frase
    if punti < soglia:
        return "NOT IN CONTEXT", ""
    return scelta, scelta

Questi sono i mattoni condivisi con la lezione sul RAG. parole toglie le parole vuote e spezza il camelCase; finestra_fissa taglia in finestre di 80 parole che scorrono di 64, così una risposta a cavallo di un confine finisce intera in almeno una finestra; modello_finto sceglie la frase con più termini della domanda e si astiene sotto la soglia. Il secondo valore che restituisce, la frase scelta, serve solo all'etichetta.

def costruisci_casi():
    casi = []
    for i, (domanda, modulo, ancora) in enumerate(GOLDEN):
        pezzi = finestra_fissa(by_id[modulo]["testo"], 80, 16)
        buono = next(p for p in pezzi if normalizza(ancora) in normalizza(p))
        casi.append({"id": f"R{i + 1:02d}", "domanda": domanda, "contesto": buono,
                     "atteso": ancora, "risponde": True})
        # famiglia N: il chunk di un ALTRO modulo che condivide piu' parole con la domanda
        # e non contiene la risposta (il trabocchetto piu' duro, non uno a caso)
        qt = set(parole(domanda))
        candidati = [(len(qt & set(parole(p))), -k, p)
                     for k, d in enumerate(documenti) if d["id"] != modulo
                     for p in finestra_fissa(d["testo"], 80, 16)
                     if normalizza(ancora) not in normalizza(p)]
        casi.append({"id": f"N{i + 1:02d}", "domanda": domanda, "contesto": max(candidati)[2],
                     "atteso": "NOT IN CONTEXT", "risponde": False})
    return casi

CASI = costruisci_casi()
print(f"casi: {len(CASI)} ({sum(c['risponde'] for c in CASI)} con risposta, "
      f"{sum(not c['risponde'] for c in CASI)} da astensione)")
print(f"parole di contesto per caso: mediana {statistics.median(len(c['contesto'].split()) for c in CASI)}")

La selezione dei contesti per la famiglia N guarda tutte le finestre degli altri moduli e prende quella con più parole in comune con la domanda, escludendo quelle che contengono l'ancora; a parità vince la prima, grazie al secondo elemento della tupla. L'uscita dice quanti casi nascono e quanto è lungo il contesto.

casi: 36 (18 con risposta, 18 da astensione)
parole di contesto per caso: mediana 80.0

Il costo di questo golden set è stato basso perché la risposta è una stringa scritta in un docstring, e le etichette si decidono per costruzione. Quando la risposta richiede un esperto il conto cambia: ogni caso costa il tempo di una persona che sa, e quel tempo è il vero limite di qualunque valutazione. Ho dichiarato un difetto che non ho corretto: poche ancore, come harmonic_mean([40, 60]) 48.0, sono esempi di codice e non frasi, e i sistemi a copione le sbagliano per costruzione. È una domanda su 18: pesa il 5,6% della famiglia R, e lo lascio dentro dichiarandolo, perché è l'ordine di grandezza del rumore di etichettatura di un golden set vero.

I tre sistemi e l'etichetta che manca

Per valutare serve un'etichetta per ogni risposta: giusta o sbagliata. Con modelli veri la daresti leggendo, una per una. Qui i sistemi A e A2 sono programmi, quindi so da quale frase hanno copiato e posso decidere per regola: una risposta della famiglia R è giusta se la frase di origine contiene tutte le parole del riferimento, una della famiglia N è giusta se il sistema si astiene.

def sistema_estrattore(caso, soglia=2):
    """Restituisce (risposta, frase di origine). La provenienza serve solo all'etichetta."""
    return modello_finto(caso["domanda"], caso["contesto"], soglia)

def etichetta(caso, risposta, origine):
    """Etichetta di riferimento (1 = corretta). Nella vita la dai tu, leggendo; qui la
    puo' dare il programma perche' il sistema e' a copione e la frase di origine e' nota."""
    astenuta = risposta.strip() == "NOT IN CONTEXT"
    if not caso["risponde"]:
        return int(astenuta)
    return int(not astenuta and set(parole(caso["atteso"])) <= set(parole(origine)))

L'etichetta è un oracolo, e non è lecito in un caso reale: dentro questa lezione serve a fissare la verità contro cui misuro le metriche e i giudici. Nel sistema C la verità non si deduce, la scrivo io accanto a ogni risposta.

# Sistema C: NON e' un programma, e' una tabella di risposte scritte da me, con la mia etichetta
# accanto. Serve a produrre cio' che un estrattore non produce mai: risposte giuste con
# parole diverse dal riferimento, e qualche risposta plausibile e sbagliata.
RIFORMULATE = {
    "R01": ("Matches scoring below 0.6 are ignored unless you change the cutoff.", 1),
    "R02": ("The insertion index lands in front of the first equal item already stored.", 1),
    "R03": ("The new item goes after the last equal one.", 1),
    "R04": ("It does both steps in one go, which is faster than pushing then popping.", 1),
    "R05": ("An empty dataset makes it raise StatisticsError.", 1),
    "R06": ("The average speed is 50 km/hr.", 0),
    "R07": ("The smaller of the two central values.", 1),
    "R08": ("Runs of whitespace are squeezed into single spaces before cutting.", 1),
    "R09": ("Only the user who created it can read, write or enter it.", 1),
    "R10": ("No, you must delete it yourself.", 0),
    "R11": ("It takes the same time whatever the inputs, so timing attacks learn nothing.", 1),
    "R12": ("Every batch has exactly n items.", 0),
    "R13": ("Nothing is ever evicted, so the cache grows without limit.", 1),
    "R14": ("Dash stands in for plus and underscore stands in for slash.", 1),
    "R15": ("No, names that begin with a dot are skipped by the star.", 1),
    "R16": ("Yes, the current time is stored in the header.", 0),
    "R17": ("It gives zero.", 1),
    "R18": ("The fraction four thirds.", 1),
    "N03": ("The new item goes after the last equal one.", 0),     # inventa dove doveva astenersi
    "N09": ("Only the user who created it can read it.", 0),
}

def sistema_riformulatore(caso):
    return RIFORMULATE.get(caso["id"], ("NOT IN CONTEXT", 1))

Le venti righe contengono quattro risposte plausibili e sbagliate nella famiglia R (R06 dice 50 km/h invece di 48, R10 dice che il file non si cancella da solo, R12 dice che ogni lotto ha esattamente n elementi, R16 dice che gzip salva l'ora corrente) e due allucinazioni nella famiglia N, dove il sistema risponde con una frase che il contesto non contiene. Le altre quattordici risposte R sono giuste e nessuna riusa le parole del riferimento: quella è la proprietà che mi serve per la sezione sulle metriche.

USCITE = {"A": [sistema_estrattore(c) for c in CASI],
          "A2": [sistema_estrattore(c, 3) for c in CASI]}
RISPOSTE = {n: [r for r, _ in u] for n, u in USCITE.items()}
VERO = {n: [etichetta(c, r, o) for c, (r, o) in zip(CASI, u)] for n, u in USCITE.items()}
RISPOSTE["C"] = [sistema_riformulatore(c)[0] for c in CASI]
VERO["C"] = [sistema_riformulatore(c)[1] for c in CASI]
print(f"{'sistema':<8}{'R (18)':>8}{'N (18)':>8}{'totale':>8}")
for nome, v in VERO.items():
    r = sum(x for x, c in zip(v, CASI) if c["risponde"]); n = sum(v) - r
    print(f"{nome:<8}{r:>8}{n:>8}{sum(v):>6}/36")
sistema   R (18)  N (18)  totale
A              8      14    22/36
A2             5      17    22/36
C             14      16    30/36

Come leggere la tabella: A e A2 hanno la stessa accuratezza totale, 22 su 36, ma non gli stessi casi giusti. A2 si astiene di più e guadagna sulla famiglia N (da 14 a 17), perdendo sulla famiglia R (da 8 a 5). La media li dà uguali, e la sezione sulle regressioni mostra perché non lo sono. C vale 30 su 36 e bilancia le due famiglie: sbaglia quattro volte dove avrebbe dovuto rispondere e due dove avrebbe dovuto tacere.

Trentasei casi non bastano a ordinare due sistemi, e si calcola

L'accuratezza su un golden set è la frequenza di successo di n prove binarie: una proporzione, con la sua incertezza. L'intervallo di Wald, p̂ ± z·√(p̂(1 − p̂)/n), su campioni piccoli può uscire da [0, 1] e copre meno del 95% dichiarato. Si usa l'intervallo di Wilson, che tira il centro verso 0,5 di una quantità che svanisce al crescere di n e allarga il raggio per la discretezza del conteggio.

from scipy.stats import norm

def wilson(successi, n, confidenza=0.95):
    z = norm.ppf(1 - (1 - confidenza) / 2)
    p = successi / n
    centro = (p + z * z / (2 * n)) / (1 + z * z / n)
    raggio = (z / (1 + z * z / n)) * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return centro - raggio, centro + raggio

def n_necessario(p1, p2, alpha=0.05, potenza=0.80):
    """Due proporzioni indipendenti, test a due code: n per gruppo."""
    z_a, z_b = norm.ppf(1 - alpha / 2), norm.ppf(potenza)
    pm = (p1 + p2) / 2                          # proporzione comune sotto l'ipotesi nulla
    num = (z_a * np.sqrt(2 * pm * (1 - pm))
           + z_b * np.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
    return int(np.ceil(num / (p1 - p2) ** 2))

print("Wilson al 95% per un'accuratezza osservata di 0,70")
print(f"{'n':>6}{'inferiore':>11}{'superiore':>11}{'ampiezza':>10}{'quanto 1/n':>12}")
for n in (20, 36, 100, 400, 1000, 4000):
    lo, hi = wilson(round(0.70 * n), n)
    print(f"{n:>6}{lo:>11.4f}{hi:>11.4f}{hi - lo:>10.4f}{1 / n:>12.4f}")
print("n per sistema, due proporzioni indipendenti, 5% a due code, potenza 80%")
for p2 in (0.75, 0.80, 0.90):
    print(f"  0.70 contro {p2:.2f}  (differenza {p2 - 0.70:.2f})  n = {n_necessario(0.70, p2)}")
for nome in ("A", "C"):
    s = sum(VERO[nome]); lo, hi = wilson(s, len(CASI))
    print(f"sistema {nome}: {s}/{len(CASI)} = {s / len(CASI):.3f}  Wilson 95% [{lo:.4f}, {hi:.4f}]")

La seconda funzione risponde alla domanda a monte: dati due tassi veri, quanti casi per sistema servono perché il test a due code, al 5%, trovi la differenza l'80% delle volte. Il termine pm è la proporzione comune sotto l'ipotesi nulla; senza di lui si userebbe la stessa varianza anche quando i due tassi sono lontani.

Wilson al 95% per un'accuratezza osservata di 0,70
     n  inferiore  superiore  ampiezza  quanto 1/n
    20     0.4810     0.8545    0.3735      0.0500
    36     0.5314     0.8200    0.2885      0.0278
   100     0.6042     0.7811    0.1769      0.0100
   400     0.6534     0.7428    0.0895      0.0025
  1000     0.6709     0.7276    0.0567      0.0010
  4000     0.6856     0.7140    0.0284      0.0003
n per sistema, due proporzioni indipendenti, 5% a due code, potenza 80%
  0.70 contro 0.75  (differenza 0.05)  n = 1251
  0.70 contro 0.80  (differenza 0.10)  n = 294
  0.70 contro 0.90  (differenza 0.20)  n = 62

Con venti domande e accuratezza osservata 0,70 l'intervallo va da 0,4810 a 0,8545: un sistema al 48% e uno all'85% sono entrambi compatibili con la misura. Il «quanto» è 1/n, la più piccola differenza che il golden set può esprimere: a venti casi vale 0,05, quindi un miglioramento del 5% non è piccolo, è il più piccolo esistente, con un'incertezza sette volte più grande di lui. Per distinguere 0,70 da 0,75 servono 1.251 casi per sistema; per 0,70 contro 0,90 ne bastano 62.

sistema A: 22/36 = 0.611  Wilson 95% [0.4486, 0.7522]
sistema C: 30/36 = 0.833  Wilson 95% [0.6811, 0.9213]

I miei due sistemi, misurati sui 36 casi, hanno intervalli di Wilson di 0,4486-0,7522 (A) e 0,6811-0,9213 (C): si sovrappongono per circa sette punti (da 0,68 a 0,75). Letti come due medie, non concludono. È la regola da portare via: decidi la differenza più piccola che vuoi poter leggere e ricava n da quella, non il contrario. Se ti interessa il 5% e il budget ti dà trentasei casi, non hai un golden set piccolo, hai uno strumento che non misura ciò che vuoi misurare.

Esercizio 1

Il quanto del golden set

Hai un golden set da 48 domande. Il sistema A ne indovina 36, il sistema B ne indovina 38. Per i conti approssima z con 2 invece di 1,96. Scrivi le due accuratezze come frazioni ridotte e la differenza fra le due, e dì qual è la differenza non nulla più piccola che questo golden set può esprimere. Calcola poi la semi-ampiezza dell'intervallo di Wald per il sistema A, z·√(p̂(1 − p̂)/n), e verifica se la differenza misurata ci sta dentro. Infine ricava quante domande servirebbero perché quella semi-ampiezza scendesse a 1/20, lasciando l'accuratezza osservata a 3/4.

Mostra la soluzione

1. 36/48 = 3/4 = 0,75 e 38/48 = 19/24 ≈ 0,7917; la differenza è 2/48 = 1/24 ≈ 0,0417. La differenza non nulla più piccola esprimibile è 1/48 ≈ 0,0208: tutto ciò che il golden set può dire sta su quella griglia, e un «miglioramento dell'1%» non esiste come misura.

2. p̂(1 − p̂)/n = (3/4)·(1/4)/48 = 3/768 = 1/256, la cui radice è esattamente 1/16. Con z = 2 la semi-ampiezza è 1/8 = 0,125, e l'intervallo è [0,625; 0,875]. La differenza misurata, 1/24, è tre volte più piccola della semi-ampiezza: sta dentro con larghissimo margine, quindi i due sistemi non sono distinguibili da questa misura.

3. Si risolve 2·√(3/(16n)) ≤ 1/20, cioè 3/(4n) ≤ 1/400, da cui n ≥ 300. Servono 300 domande per leggere cinque punti: più di sei volte il golden set che hai, per guadagnare un fattore due e mezzo sull'ampiezza. L'incertezza scende come 1/√n, ed è per questo che dimezzarla costa quadruplicare l'etichettatura.

Il confronto appaiato, e quando non aiuta

Le 1.251 domande per sistema valgono per due campioni indipendenti. Ma A e C rispondono alle stesse domande, quindi la difficoltà dei casi è comune e si può togliere. Il test di McNemar guarda solo i casi discordanti, quelli in cui un sistema ha ragione e l'altro no: sotto l'ipotesi che i due sistemi siano equivalenti, ogni discordante è una moneta equa, e il test diventa un test binomiale esatto. In parallelo calcolo un intervallo bootstrap della differenza, ricampionando i casi e non le righe: la coppia resta unita, ed è ciò che lo rende appaiato.

from scipy.stats import binomtest

def discordanti(x, y):
    return (sum(1 for a, b in zip(x, y) if a and not b),     # solo il primo indovina
            sum(1 for a, b in zip(x, y) if b and not a))     # solo il secondo indovina

def mcnemar_esatto(x, y):
    n10, n01 = discordanti(x, y)
    # i concordanti non entrano: sotto l'ipotesi nulla ogni discordante e' una moneta equa
    return n10, n01, binomtest(n10, n10 + n01, 0.5).pvalue if n10 + n01 else 1.0

def bootstrap_differenza(x, y, ripetizioni=10_000, seme=7):
    """Intervallo percentile al 95% della differenza di accuratezza, ricampionando i CASI
    (la coppia x[i], y[i] resta unita: e' questo che rende il confronto appaiato)."""
    rng = np.random.default_rng(seme)
    d = np.array(x) - np.array(y)
    idx = rng.integers(0, len(d), size=(ripetizioni, len(d)))
    medie = d[idx].mean(axis=1)
    return float(d.mean()), float(np.percentile(medie, 2.5)), float(np.percentile(medie, 97.5))

n10, n01, p = mcnemar_esatto(VERO["A"], VERO["C"])
conc = len(CASI) - n10 - n01
print(f"A contro C: concordanti {conc}, solo A {n10}, solo C {n01}, McNemar esatto p = {p:.4f}")
d, lo, hi = bootstrap_differenza(VERO["A"], VERO["C"])
print(f"differenza A - C = {d:+.3f}, bootstrap appaiato 95% [{lo:+.3f}, {hi:+.3f}]")
sa, sb = sum(VERO["A"]), sum(VERO["C"])
print(f"Wilson A [{wilson(sa, 36)[0]:.3f}, {wilson(sa, 36)[1]:.3f}] e C [{wilson(sb, 36)[0]:.3f}, {wilson(sb, 36)[1]:.3f}]: si sovrappongono")
A contro C: concordanti 22, solo A 3, solo C 11, McNemar esatto p = 0.0574
differenza A - C = -0.222, bootstrap appaiato 95% [-0.417, -0.028]
Wilson A [0.449, 0.752] e C [0.681, 0.921]: si sovrappongono

Dei 36 casi, 22 sono concordanti e non portano informazione. Nei 14 discordanti C ha ragione 11 volte e A 3: un esito 11 a 3 su una moneta equa ha probabilità esatta p = 0,0574, appena sopra il 5%. Il bootstrap appaiato dà invece un intervallo della differenza che esclude lo zero, da −0,417 a −0,028. I due strumenti guardano lo stesso dato e si schierano su lati opposti della soglia: è la risposta onesta di una misura al limite, e non va risolta scegliendo lo strumento che ti dà ragione. Con 14 discordanti il bootstrap percentile tende ad essere ottimista, ma questa è un'avvertenza generale che qui non ho verificato con una simulazione dedicata.

Resta la domanda se l'appaiato convenga sempre. Lo provo con sistemi simulati su 100 casi, dove controllo quanto la difficoltà è condivisa fra i due.

def simula_sistemi(n, a1, a2, rigidita, rng):
    """Due sistemi SIMULATI sugli stessi n casi: ogni caso ha una difficolta' latente
    comune; `rigidita` dice quanto pesa (0 = casi indipendenti, 3 = molto condivisa)."""
    difficolta = rng.normal(size=n) * rigidita
    p = lambda a: 1 / (1 + np.exp(-(a - difficolta)))
    return (rng.random(n) < p(a1)).astype(int), (rng.random(n) < p(a2)).astype(int)

def potenza_simulata(n, a1, a2, rigidita, prove=1000, seme=11):
    rng = np.random.default_rng(seme)
    vinte_nonappaiato = vinte_appaiato = 0
    medie = np.zeros(2)
    for _ in range(prove):
        x, y = simula_sistemi(n, a1, a2, rigidita, rng)
        medie += (x.mean(), y.mean())
        pm = (x.mean() + y.mean()) / 2
        z = (x.mean() - y.mean()) / np.sqrt(2 * pm * (1 - pm) / n) if 0 < pm < 1 else 0.0
        vinte_nonappaiato += abs(z) > 1.96
        vinte_appaiato += mcnemar_esatto(x, y)[2] < 0.05
    return vinte_nonappaiato / prove, vinte_appaiato / prove, medie / prove

print("potenza su 1000 prove simulate, n = 100 casi, stessi due sistemi vs difficolta' condivisa")
print(f"{'rigidita':>9}{'acc. 1':>8}{'acc. 2':>8}{'non appaiato':>14}{'McNemar':>10}")
for rigidita in (0.0, 1.5, 3.0):
    a, b, (m1, m2) = potenza_simulata(100, 0.847, 1.386, rigidita)
    print(f"{rigidita:>9.1f}{m1:>8.3f}{m2:>8.3f}{a:>14.3f}{b:>10.3f}")
potenza su 1000 prove simulate, n = 100 casi, stessi due sistemi vs difficolta' condivisa
 rigidita  acc. 1  acc. 2  non appaiato   McNemar
      0.0   0.697   0.799         0.396     0.328
      1.5   0.645   0.730         0.220     0.257
      3.0   0.597   0.657         0.054     0.176

Con casi indipendenti (rigidità 0) il test non appaiato vince di poco, 0,396 contro 0,328, perché l'esatto è conservativo. Quando la difficoltà si condivide il vantaggio si inverte: a rigidità 3 il non appaiato trova la differenza solo il 5,4% delle volte e McNemar il 17,6%. Le accuratezze medie calano al crescere della rigidità, perché la difficoltà comune spinge tutti verso il basso, quindi la lettura corretta è: il vantaggio dell'appaiato dipende da quanto i due sistemi sbagliano sugli stessi casi, e in un golden set vero lo scopri contando i concordanti.

Il prezzo è una condizione che si viola per distrazione: le domande devono essere letteralmente le stesse, nello stesso ordine, con lo stesso contesto. Se fra le due valutazioni cambia anche solo il recupero, e in un RAG cambia a ogni reindicizzazione, il confronto non è più appaiato. Per questo il golden set si congela in un file versionato, con un identificativo per caso.

Le metriche automatiche e il lessico

Etichettare a mano a ogni commit non è sostenibile, e qui entrano le metriche automatiche, che confrontano la risposta con il riferimento senza capire né l'una né l'altro. Sono tre, in ordine di severità. L'exact match vale uno se le due stringhe normalizzate coincidono. L'F1 sui token è la media armonica di precisione e richiamo sulle parole in comune, come nella valutazione di SQuAD. L'F1 sulla sottosequenza comune più lunga è la stessa media armonica sulla LCS, il nucleo di ROUGE-L, e a differenza della precedente tiene conto dell'ordine delle parole.

import string
ARTICOLI = {"a", "an", "the"}

def norm_metrica(t):
    t = t.lower().translate(str.maketrans("", "", string.punctuation))
    return [w for w in t.split() if w not in ARTICOLI]

def f1_token(prev, rif):
    a, b = norm_metrica(prev), norm_metrica(rif)
    if not a or not b:
        return float(a == b)             # due vuoti sono uguali, uno vuoto no
    da = collections.Counter(b)
    comuni = 0
    for w in a:
        if da[w] > 0:                    # con molteplicita': tre "the" non contano tre volte
            comuni += 1
            da[w] -= 1
    if comuni == 0:
        return 0.0                       # evita lo 0/0 nella media armonica
    p, r = comuni / len(a), comuni / len(b)
    return 2 * p * r / (p + r)

def exact_match(prev, rif):
    return float(norm_metrica(prev) == norm_metrica(rif))

def f1_lcs(prev, rif):
    a, b = norm_metrica(prev), norm_metrica(rif)
    M = np.zeros((len(a) + 1, len(b) + 1), dtype=int)
    for i, x in enumerate(a, 1):
        for j, y in enumerate(b, 1):
            M[i, j] = M[i - 1, j - 1] + 1 if x == y else max(M[i - 1, j], M[i, j - 1])
    L = int(M[len(a), len(b)])
    if L == 0:
        return 0.0
    p, r = L / len(a), L / len(b)
    return 2 * p * r / (p + r)

La normalizzazione toglie punteggiatura, maiuscole e articoli, perché "A list of paths." e "a list of paths" sono la stessa risposta. Il conteggio con molteplicità nel Counter impedisce che una risposta che ripete dieci volte una parola del riferimento guadagni dieci punti di sovrapposizione, e i due return 0.0 prima della media armonica evitano la divisione per zero quando non c'è nessun token in comune.

METRICHE = {"exact match": exact_match, "F1 token": f1_token, "F1 su LCS": f1_lcs}
print(f"{'sistema':<9}{'metrica':<13}{'media':>7}{'accordo':>9}{'falsi neg.':>12}{'falsi pos.':>12}")
for nome in ("A", "C"):
    for mn, m in METRICHE.items():
        punti = [m(r, c["atteso"]) for r, c in zip(RISPOSTE[nome], CASI)]
        verdetto = [int(p >= 0.5) for p in punti]
        v = VERO[nome]
        fn = sum(1 for x, y in zip(verdetto, v) if y and not x)
        fp = sum(1 for x, y in zip(verdetto, v) if x and not y)
        accordo = sum(int(x == y) for x, y in zip(verdetto, v)) / len(v)
        print(f"{nome:<9}{mn:<13}{sum(punti) / len(punti):>7.3f}{accordo:>9.3f}{fn:>12}{fp:>12}")
print("risposte GIUSTE del sistema C con l'F1 piu' basso")
giuste_c = sorted((f1_token(r, c["atteso"]), c["id"], r, c["atteso"])
                  for c, r, v in zip(CASI, RISPOSTE["C"], VERO["C"]) if v and c["risponde"])
for f1, i, r, rif in giuste_c[:3]:
    print(f"{i}  F1 {f1:.3f}  exact match {exact_match(r, rif):.0f}")
    print(f"   riferimento: {rif}")
    print(f"   risposta   : {r}")
sistema  metrica        media  accordo  falsi neg.  falsi pos.
A        exact match    0.417    0.806           7           0
A        F1 token       0.601    0.972           1           0
A        F1 su LCS      0.601    0.972           1           0
C        exact match    0.444    0.611          14           0
C        F1 token       0.547    0.667          12           0
C        F1 su LCS      0.537    0.639          13           0

L'«accordo» è la frazione di risposte su cui la metrica, con soglia 0,5, dà il mio stesso verdetto. Sul sistema A l'F1 arriva a 0,972: un solo errore su 36. Sul sistema C scende a 0,667, con 12 falsi negativi e nessun falso positivo. Il contrasto è la lezione: le metriche lessicali non sono né buone né cattive, dipendono da come scrive il sistema. Un estrattore ricopia il riferimento, quindi l'F1 lo giudica bene; un sistema che riformula, con la stessa correttezza, viene punito.

risposte GIUSTE del sistema C con l'F1 piu' basso
R03  F1 0.000  exact match 0
   riferimento: insert it to the right of the rightmost x
   risposta   : The new item goes after the last equal one.
R18  F1 0.000  exact match 0
   riferimento: Fraction(8, 6) will produce a rational number equivalent to 4/3
   risposta   : The fraction four thirds.
R04  F1 0.080  exact match 0
   riferimento: runs more efficiently than heappush() followed by a separate call to heappop()
   risposta   : It does both steps in one go, which is faster than pushing then popping.

Tre risposte giuste del sistema C hanno F1 pari a 0,000, 0,000 e 0,080. «The fraction four thirds» è la risposta giusta a «Fraction(8, 6) will produce a rational number equivalent to 4/3» e non condivide una sola parola con il riferimento. L'F1 non misura la correttezza: misura quanto la risposta somiglia a come l'avrebbe scritta chi ha scritto il riferimento. Conviene usarle comunque quando la risposta giusta è una stringa canonica (un nome di eccezione, un codice d'errore, un identificativo, un'etichetta di classe): lì la riformulazione è un bug e l'exact match è la metrica, non un ripiego.

Esercizio 2

Due risposte corrette, due F1 incompatibili

Lo span di riferimento è Return the current action for the given signal. Due sistemi rispondono The current action for the given signal. e What the signal handler is set to right now., e sono entrambi corretti. Normalizza come fa il codice della sezione (minuscole, via punteggiatura e articoli a, an, the), poi calcola exact match e F1 sui token per entrambe, in frazione ridotta. Dì che cosa decide un banco di prova che usa l'F1 con soglia 0,5.

Mostra la soluzione

Riferimento normalizzato: [return, current, action, for, given, signal], sei token. L'exact match è 0 per entrambe le risposte: nessuna delle due coincide con il riferimento.

Risposta 1: [current, action, for, given, signal], cinque token, tutti e cinque in comune. Precisione 5/5 = 1, richiamo 5/6, quindi F1 = 2·1·(5/6)/(1 + 5/6) = 10/11 ≈ 0,9091.

Risposta 2: [what, signal, handler, is, set, to, right, now], otto token, uno solo in comune (signal). Precisione 1/8, richiamo 1/6, quindi F1 = 2·(1/8)·(1/6)/(1/8 + 1/6) = (1/24)/(7/24) = 1/7 ≈ 0,1429.

Che cosa decide il banco di prova. Con soglia 0,5 promuove la prima e boccia la seconda, che è corretta: un falso negativo. Se il sistema due fosse la versione nuova del sistema uno, il banco riporterebbe un crollo da 0,91 a 0,14 provocato da una riscrittura che ha solo cambiato lo stile. La differenza fra 10/11 e 1/7 non è di qualità: è di lessico.

Il giudice automatico e l'accordo misurato col kappa di Cohen

Se l'exact match è troppo severo e l'F1 misura il lessico, la mossa successiva è chiedere a un modello di fare ciò che io ho fatto a mano: dire se la risposta porta la stessa informazione del riferimento. È il giudice automatico reso popolare dal lavoro su MT-Bench e Chatbot Arena, che discute anche i suoi pregiudizi di posizione, di verbosità e di preferenza per sé stesso. Il prompt del giudice e il parser del verdetto sono questi.

GIUDICE = """You are grading a candidate answer against a reference answer.
QUESTION: {domanda}
REFERENCE: {atteso}
CANDIDATE: {risposta}
Does the candidate answer convey the same information as the reference?
Reply with exactly one word: CORRECT or WRONG."""

def verdetto(testo):
    t = testo.strip().upper()
    if re.search(r"\bINCORRECT\b|\bWRONG\b", t):
        return 0
    if re.search(r"\bCORRECT\b", t):
        return 1
    return None            # fuori formato: va contato, non convertito in silenzio

Il verdetto si chiede come una parola sola, non dentro una motivazione, e la funzione restituisce None quando l'uscita non è nel formato chiesto: quel conteggio va riportato, perché un giudice fuori formato nel 20% dei casi non è severo, è rotto. Il prompt è lo schema da adattare al tuo modello e non è stato eseguito; il parser invece gira, ed è lì che si vedono le sue trappole.

for uscita in ("CORRECT", "WRONG.", "Incorrect", "The answer is correct because...", "Sure! WRONG"):
    print(f"{uscita!r:<40} -> {verdetto(uscita)}")

Cinque uscite tipiche di un modello, passate al parser: due pulite, una con punteggiatura, una che ragiona e una con un prefisso di cortesia.

'CORRECT'                                -> 1
'WRONG.'                                 -> 0
'Incorrect'                              -> 0
'The answer is correct because...'       -> 1
'Sure! WRONG'                            -> 0

L'ultima riga è un caso che il parser accetta e non dovrebbe: «The answer is correct because...» diventa 1 anche se il modello ha iniziato a ragionare invece di rispondere con una parola. Un parser troppo permissivo nasconde un giudice che non rispetta il formato; la contromisura è esigere che l'uscita, tolti spazi e punteggiatura finale, sia esattamente una delle due parole.

L'accuratezza del giudice contro le mie etichette non basta: su un insieme sbilanciato, un giudice che dice sempre la classe maggioritaria ha accuratezza alta e informazione nulla. Serve il kappa di Cohen, che misura l'accordo al netto del caso: κ = (p_o − p_e) / (1 − p_e), dove p_o è l'accordo osservato e p_e quello che otterresti combinando a caso le due frequenze marginali. Giudice perfetto κ = 1, indistinguibile dal caso κ = 0, sistematicamente in disaccordo κ negativo.

def rng_di(*chiavi):
    """Un generatore per OGNI decisione, derivato dal nome del giudice e dal caso: stesso
    caso, stesso giudice, stesso verdetto, in qualunque ordine si chiami."""
    return np.random.default_rng(_h("|".join(map(str, chiavi))))

def _h(s):
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "big")

class GiudiceSimulato:
    """NON e' un modello. Dato il vero (che gli passiamo noi), sbaglia con probabilita' fissate:
    falsi negativi in parte piu' alti quando la risposta ha poche parole in comune col
    riferimento (e' l'errore tipico di chi giudica la forma), falsi positivi a tasso fisso."""
    def __init__(self, nome, p_fn, extra_fn_forma, p_fp):
        self.nome, self.p_fn, self.extra, self.p_fp = nome, p_fn, extra_fn_forma, p_fp

    def verdetto(self, caso, risposta, vero):
        r = rng_di(self.nome, caso["id"], risposta).random()
        if vero:
            diversa = f1_token(risposta, caso["atteso"]) < 0.5
            return int(r >= self.p_fn + (self.extra if diversa else 0.0))
        return int(r < self.p_fp)

class GiudiceARegole:
    """Un giudice vero e deterministico: CORRECT se l'F1 sui token supera la soglia."""
    nome = "a regole"
    def verdetto(self, caso, risposta, vero):
        return int(f1_token(risposta, caso["atteso"]) >= 0.5)

GIUDICI = [GiudiceSimulato("G1 pigro", 0.0, 0.0, 1.0),       # dice sempre CORRECT
           GiudiceSimulato("G2 severo", 0.30, 0.30, 0.02),
           GiudiceSimulato("G3 buono", 0.08, 0.10, 0.05),
           GiudiceARegole()]

Il punto delicato è rng_di: ogni decisione ha il proprio generatore, derivato dal nome del giudice, dal caso e dalla risposta. Ne segue che lo stesso giudice dà lo stesso verdetto sullo stesso input in qualunque ordine lo chiami, e che aggiungere un caso non sposta i verdetti degli altri. Il generatore globale con un solo seme avrebbe invece fatto dipendere ogni verdetto da quanti ne erano stati chiesti prima. I falsi negativi del giudice simulato crescono quando la risposta ha poche parole in comune con il riferimento: è l'errore tipico di chi giudica la forma.

def kappa(y, x):
    n = len(y)
    po = sum(int(a == b) for a, b in zip(y, x)) / n
    pe = sum((y.count(c) / n) * (x.count(c) / n) for c in (0, 1))
    return (po - pe) / (1 - pe) if pe < 1 else 0.0

TUTTE = [(c, n, RISPOSTE[n][i], VERO[n][i]) for n in ("A", "A2", "C") for i, c in enumerate(CASI)]
y = [t[3] for t in TUTTE]
print(f"{'giudice':<10}{'accordo':>8}{'kappa':>8}{'FN':>5}{'FP':>5}{'dice CORRECT':>14}")
VERDETTI = {}
for g in GIUDICI:
    x = [g.verdetto(c, r, v) for c, _, r, v in TUTTE]
    VERDETTI[g.nome] = x
    fn = sum(1 for a, b in zip(x, y) if b and not a); fp = sum(1 for a, b in zip(x, y) if a and not b)
    print(f"{g.nome:<10}{sum(int(a == b) for a, b in zip(x, y)) / len(y):>8.3f}{kappa(y, x):>8.3f}"
          f"{fn:>5}{fp:>5}{sum(x) / len(x):>14.3f}")
print(f"{'le mie':<10}{1.0:>8.3f}{1.0:>8.3f}{0:>5}{0:>5}{sum(y) / len(y):>14.3f}   ({len(y)} verdetti)")
print("accuratezza attribuita ai sistemi da ciascun giudice (e secondo le mie etichette)")
for g in GIUDICI:
    riga = {n: sum(VERDETTI[g.nome][k] for k, t in enumerate(TUTTE) if t[1] == n) / 36 for n in ("A", "A2", "C")}
    print(f"  {g.nome:<10}" + "  ".join(f"{n} {v:.3f}" for n, v in riga.items()))
print("  " + f"{'le mie':<10}" + "  ".join(f"{n} {sum(VERO[n]) / 36:.3f}" for n in ("A", "A2", "C")))
giudice    accordo   kappa   FN   FP  dice CORRECT
G1 pigro     0.685   0.000    0   34         1.000
G2 severo    0.667   0.399   36    0         0.352
G3 buono     0.843   0.659   13    4         0.602
a regole     0.870   0.730   14    0         0.556
le mie       1.000   1.000    0    0         0.685   (108 verdetti)

Riga per riga. G1 dice sempre CORRECT: accordo 0,685 ma κ = 0,000, perché il 68,5% delle risposte è davvero giusto e dire sempre «giusto» azzecca quella quota senza guardare niente. G2 è severo, 36 falsi negativi su 108, con κ = 0,399. G3 ha κ = 0,659. Il giudice a regole, che è un programma vero, ha l'accordo più alto (0,870) e il kappa più alto (0,730). Se ti fermassi qui sceglieresti lui.

accuratezza attribuita ai sistemi da ciascun giudice (e secondo le mie etichette)
  G1 pigro  A 1.000  A2 1.000  C 1.000
  G2 severo A 0.333  A2 0.306  C 0.417
  G3 buono  A 0.500  A2 0.556  C 0.750
  a regole  A 0.583  A2 0.583  C 0.500
  le mie    A 0.611  A2 0.611  C 0.833

Il secondo prospetto cambia il verdetto. Quello che conta per decidere è l'ordine dei sistemi, e il giudice a regole mette C (0,500) sotto A (0,583) quando secondo le mie etichette C vale 0,833 e A 0,611. Il suo kappa è alto perché la maggior parte dei casi sono estrazioni letterali, dove l'F1 va bene; 12 dei suoi 14 falsi negativi stanno sul sistema C, quello che riformula. G3, meno accurato in assoluto, ha l'ordine giusto (A 0,500, C 0,750). Un errore concentrato su un sistema non si legge nel kappa aggregato, e si smaschera solo guardando la distribuzione degli errori per sistema. I livelli assoluti sono comunque incomparabili: lo stesso sistema C vale 0,833 per me, 0,750 per G3, 0,417 per G2 e 1,000 per G1.

Esercizio 3

Il kappa smaschera il giudice pigro

Hai etichettato a mano 100 risposte e le hai confrontate con le etichette di un giudice automatico. Entrambi dicono «corretta» su 60 risposte, entrambi dicono «sbagliata» su 20; tu dici «corretta» e il giudice «sbagliata» su 15, tu dici «sbagliata» e il giudice «corretta» su 5. Calcola p_o, p_e e κ in frazione ridotta. Poi fai lo stesso per un secondo giudice che dice sempre «corretta» su tutte e 100 le risposte, e confronta i due valori di accuratezza e i due valori di κ.

Mostra la soluzione

Primo giudice. p_o = (60 + 20)/100 = 4/5 = 0,8. Tu dici corretta 75 volte e sbagliata 25, il giudice corretta 65 e sbagliata 35. Quindi p_e = (75/100)·(65/100) + (25/100)·(35/100) = 23/40 = 0,575, e κ = 0,225/0,425 = 9/17 ≈ 0,5294: accordo moderato.

Secondo giudice. Azzecca le tue 75 «corrette» e sbaglia le tue 25 «sbagliate»: p_o = 3/4 = 0,75. I suoi margini sono 100 e 0, quindi p_e = (75/100)·1 + (25/100)·0 = 3/4, identico a p_o, e κ = 0.

La morale in una riga. Il secondo giudice ha il 75% di accuratezza e zero informazione: ogni suo verdetto era prevedibile senza guardare la risposta. L'accuratezza di un giudice non va riportata da sola, e la quota di «corretta» è la seconda cosa da guardare dopo il kappa.

Primo pregiudizio: la posizione

Il modo più usato di far giudicare un sistema è il confronto a coppie: due risposte alla stessa domanda e la scelta della migliore. È più facile per il giudice e ha un difetto che si misura in pochi minuti: il verdetto può dipendere dall'ordine in cui le risposte sono incollate nel prompt. È uno dei risultati del lavoro di Wang e colleghi sugli LLM come arbitri, dove l'ordine di presentazione bastava a far vincere l'uno o l'altro modello. Qui lo provo su un giudice simulato, per mostrare come si misura.

def sig(x):
    return 1 / (1 + math.exp(-x))

class GiudiceDiCoppia:
    """Confronto a coppie SIMULATO. Sceglie la risposta migliore con abilita' `abilita`
    (in logit per ogni punto di differenza di verita'), piu' un vantaggio `beta` per chi sta
    in posizione A e un vantaggio `bonus` per la risposta del proprio sistema."""
    def __init__(self, nome, abilita=2.5, beta=0.0, proprio=None, bonus=0.0):
        self.nome, self.abilita, self.beta, self.proprio, self.bonus = nome, abilita, beta, proprio, bonus

    def sceglie_A(self, caso, in_a, in_b, vero_a, vero_b):
        logit = self.abilita * (vero_a - vero_b) + self.beta
        logit += self.bonus * ((in_a == self.proprio) - (in_b == self.proprio))
        return int(rng_di(self.nome, caso["id"], in_a, in_b).random() < sig(logit))

def due_ordini(g, caso, x, y, vero_x, vero_y):
    v_ab = g.sceglie_A(caso, x, y, vero_x, vero_y)           # x in posizione A
    v_ba = g.sceglie_A(caso, y, x, vero_y, vero_x)           # ora in posizione A sta y
    # ATTENZIONE: nella seconda passata "A" indica l'ALTRO sistema. Stessa lettera nelle
    # due passate = il giudice ha scelto due sistemi diversi sulla stessa coppia.
    ribaltato = (v_ab == 1) == (v_ba == 1)
    vittoria_x = (v_ab + (1 - v_ba)) / 2
    return ribaltato, v_ab, v_ba, vittoria_x

La riga che conta è il commento in due_ordini. Nella seconda passata la lettera A non indica più lo stesso sistema, quindi un verdetto A in entrambe le passate significa che il giudice ha scelto due sistemi diversi sulla stessa coppia: è una contraddizione, non una conferma. Chi dimentica lo scambio conta le contraddizioni come accordi e pubblica un tasso di ribaltamento vicino a zero. L'abilità vale 2,5 logit per punto di differenza di verità, il vantaggio di posizione (beta) è 0 per il giudice neutro, +1,2 per chi preferisce la prima posizione e −1,2 per chi preferisce la seconda.

COPPIE = [(i, c) for i, c in enumerate(CASI) if VERO["A"][i] != VERO["C"][i]]
print(f"coppie A contro C con verita' diverse: {len(COPPIE)}/{len(CASI)} "
      f"(A migliore in {sum(VERO['A'][i] > VERO['C'][i] for i, _ in COPPIE)}, "
      f"C migliore in {sum(VERO['C'][i] > VERO['A'][i] for i, _ in COPPIE)})")
print(f"{'giudice':<22}{'ribaltati':>10}{'prima pos.':>12}{'A, solo AB':>12}{'A, mediato':>12}")
for g in (GiudiceDiCoppia("neutro", beta=0.0), GiudiceDiCoppia("primacy", beta=1.2),
          GiudiceDiCoppia("recency", beta=-1.2)):
    ris = [due_ordini(g, c, "A", "C", VERO["A"][i], VERO["C"][i]) for i, c in COPPIE]
    rib = sum(r[0] for r in ris); prima = sum(r[1] + r[2] for r in ris)
    print(f"{g.nome:<22}{rib:>7}/{len(ris)}{prima:>9}/{2 * len(ris)}"
          f"{sum(r[1] for r in ris) / len(ris):>12.3f}{sum(r[3] for r in ris) / len(ris):>12.3f}")
print(f"vittorie di A secondo le mie etichette: {sum(VERO['A'][i] > VERO['C'][i] for i, _ in COPPIE) / len(COPPIE):.3f}")

def tasso_ribaltamento_atteso(abilita, beta, differenza=1):
    """Probabilita' di contraddirsi su una coppia, in forma chiusa (no campionamento)."""
    p_ab = sig(abilita * differenza + beta)               # sceglie A con x in A
    p_ba = sig(-abilita * differenza + beta)              # sceglie A con y in A
    return p_ab * p_ba + (1 - p_ab) * (1 - p_ba)
print("tasso di ribaltamento atteso su una coppia con verita' diverse")
for beta in (0.0, 0.5, 1.2, 2.0):
    print(f"  beta {beta:.1f}  ->  {tasso_ribaltamento_atteso(2.5, beta):.3f}")
coppie A contro C con verita' diverse: 14/36 (A migliore in 3, C migliore in 11)
giudice                ribaltati  prima pos.  A, solo AB  A, mediato
neutro                      0/14       14/28       0.286       0.286
primacy                     5/14       19/28       0.500       0.321
recency                     3/14       11/28       0.214       0.321
vittorie di A secondo le mie etichette: 0.214

Le coppie utili sono 14 su 36, quelle in cui A e C hanno etichette diverse: nelle altre 22 il confronto non ha un vincitore vero. Secondo le mie etichette A vince il 21,4% delle volte. Il giudice neutro lo dà al 28,6%, sia in una passata sola sia mediando. Il giudice con vantaggio sulla prima posizione dà ad A il 50,0% leggendo la sola passata AB, cioè lo dichiara alla pari con C; mediando i due ordini il valore scende a 0,321, vicino alla verità. Quello con vantaggio sulla seconda posizione fa l'opposto: 0,214 in una passata, 0,321 mediato. Una passata sola avrebbe cambiato la conclusione in entrambe le direzioni.

tasso di ribaltamento atteso su una coppia con verita' diverse
  beta 0.0  ->  0.140
  beta 0.5  ->  0.155
  beta 1.2  ->  0.228
  beta 2.0  ->  0.380

La seconda tabella è la forma chiusa: con abilità 2,5 e nessun pregiudizio un giudice si contraddice comunque nel 14% delle coppie, perché la sua scelta è probabilistica, e il tasso sale al 38% con beta = 2. Ne segue una regola pratica: il tasso di ribaltamento ha un pavimento che non è zero, e un valore esattamente nullo su poche coppie è più sospetto che rassicurante (qui il neutro ha 0 su 14, una coincidenza compatibile con un valore atteso di circa due). Un confronto a coppie si fa sempre nei due ordini, e costa il doppio delle chiamate.

Esercizio 4

Il tasso di vittoria che cambia con l'ordine

Un giudice confronta i sistemi X e Y su 12 coppie, in due passate. Nella passata AB il sistema X sta in posizione A; nella passata BA sta in posizione B. I verdetti, scritti come coppia (lettera nella passata AB, lettera nella passata BA), sono: (A, B) sei volte, (B, A) due volte, (A, A) quattro volte. Dì su quante coppie il giudice si contraddice e con quale tasso di ribaltamento, quante volte in tutto ha scelto la risposta in prima posizione su 24 verdetti, e calcola il tasso di vittoria di X leggendo solo la passata AB e poi mediando sulle due posizioni.

Mostra la soluzione

1. Il giudice si contraddice quando sceglie la stessa lettera nelle due passate, perché la lettera indica sistemi diversi: sono i quattro casi (A, A). Tasso di ribaltamento 4/12 = 1/3 ≈ 0,3333.

2. Nella passata AB ha scelto A in 6 + 4 = 10 casi; nella passata BA in 2 + 4 = 6 casi. In totale 16/24 = 2/3 dei verdetti vanno alla prima posizione, contro 1/2 atteso da un giudice senza pregiudizio.

3. Leggendo solo la passata AB, X vince dove il verdetto è A: 10/12 = 5/6 ≈ 0,8333. Mediando, i sei (A, B) valgono 1, i due (B, A) valgono 0, i quattro (A, A) valgono 0,5: 8/12 = 2/3 ≈ 0,6667. Lo scarto è 1/6 ≈ 0,1667: sedici punti regalati a X dal solo fatto di essere stato incollato per primo.

Verbosità e preferenza per sé stessi: due pregiudizi da cercare, non da ereditare

Gli altri due pregiudizi del lavoro su MT-Bench hanno una forma diversa. La verbosità è la tendenza a premiare le risposte più lunghe a parità di contenuto, tanto che AlpacaEval ha introdotto una versione con controllo della lunghezza. La preferenza per sé stessi è la tendenza di un modello a dare punteggi più alti ai propri testi, che Panickssery e colleghi hanno messo in relazione con la capacità del modello di riconoscerli. Entrambi sono stati misurati su modelli veri, e io qui non posso ripetere quelle misure: posso mostrare il disegno sperimentale e verificare che sia in grado di ritrovare un pregiudizio quando c'è.

Per la verbosità: una risposta corretta viene allungata con frasi vere e fuori tema, e si guarda come cambia il voto da 1 a 5 al crescere della lunghezza. La pendenza si stima per raddoppio di lunghezza, e l'incertezza ricampionando i casi, non le righe, perché le sei versioni della stessa risposta non sono indipendenti.

ZEPPE = ["Only the name of this class is considered a public API.",
         "A simple framework for writing line-oriented command interpreters.",
         "The default timer is a fast built-in one based on real time.",
         "An ABC can be subclassed directly, and then acts as a mix-in class.",
         "The standard debugger class is an example of this design."]

def voto_1_5(nome, caso, testo, vero, gamma):
    """Giudice di punteggio SIMULATO: base 4 se corretta e 2 se sbagliata, piu' gamma per
    ogni raddoppio di lunghezza, piu' rumore. gamma > 0 = premia chi scrive di piu'."""
    parole_ = max(1, len(testo.split()))
    grezzo = 2 + 2 * vero + gamma * math.log2(parole_ / 10) + rng_di(nome, caso["id"], testo).normal(0, 0.5)
    return float(min(5, max(1, round(grezzo))))

def pendenza_con_intervallo(gamma, nome, ripetizioni=2000, seme=3):
    giuste = [(c, RISPOSTE["C"][i]) for i, c in enumerate(CASI) if c["risponde"] and VERO["C"][i]]
    punti = []
    for c, r in giuste:
        for q in range(6):                               # cresce solo la lunghezza
            t = " ".join([r] + ZEPPE[:q])
            punti.append((len(t.split()), voto_1_5(nome, c, t, 1, gamma), c["id"]))
    lunghezze = np.log2([p[0] for p in punti]); voti = np.array([p[1] for p in punti])
    pendenza = np.polyfit(lunghezze, voti, 1)[0]
    ids = sorted({p[2] for p in punti})
    rng = np.random.default_rng(seme)
    stime = []
    for _ in range(ripetizioni):                         # si ricampionano i CASI, non le righe
        scelti = set(rng.choice(ids, size=len(ids)).tolist())
        mask = np.array([p[2] in scelti for p in punti])
        stime.append(np.polyfit(lunghezze[mask], voti[mask], 1)[0])
    return len(giuste), pendenza, np.percentile(stime, 2.5), np.percentile(stime, 97.5)

print("voto del giudice per ogni raddoppio di lunghezza (risposte giuste + zeppe vere e fuori tema)")
print(f"{'gamma vero':>11}{'casi':>6}{'pendenza':>10}{'IC 95%':>20}")
for gamma in (0.0, 0.6, -0.4):
    n, p, lo, hi = pendenza_con_intervallo(gamma, f"verbosita{gamma}")
    print(f"{gamma:>11.1f}{n:>6}{p:>10.3f}   [{lo:+.3f}, {hi:+.3f}]")
voto del giudice per ogni raddoppio di lunghezza (risposte giuste + zeppe vere e fuori tema)
 gamma vero  casi  pendenza              IC 95%
        0.0    14     0.050   [-0.031, +0.159]
        0.6    14     0.364   [+0.284, +0.435]
       -0.4    14    -0.491   [-0.603, -0.380]

Con gamma = 0 la pendenza stimata è 0,050 e l'intervallo [−0,031; +0,159] contiene lo zero: nessun pregiudizio, ed è la risposta giusta. Con gamma = +0,6 (premia la lunghezza) la pendenza è 0,364 e l'intervallo esclude lo zero; con gamma = −0,4 è −0,491 e anche questo lo esclude, dal lato opposto. Il protocollo ritrova entrambi i segni. Non ritrova il valore esatto di gamma: la pendenza è più piccola in modulo perché i voti sono interi e saturano a 1 e a 5. Dato che il segno si ritrova e l'ampiezza no, il protocollo è adatto a rispondere «c'è o non c'è», e non a stimare quanto vale.

Per la preferenza per sé stessi serve un confronto fra due giudici: quello che valuta il proprio sistema e un giudice terzo che valuta lo stesso. Il pregiudizio è lo scarto fra i due tassi di vittoria, mediato sui due ordini. Qui la domanda utile non è quanto vale, ma da quante coppie in su sei in grado di vederlo.

def scarti_per_coppia(bonus, n_coppie, seme):
    """Per ogni coppia sintetica: tasso di vittoria (mediato sui due ordini) che un giudice
    con `bonus` per il proprio sistema gli da', meno quello di un giudice terzo senza bonus."""
    rng = np.random.default_rng(seme)
    d = rng.choice([-1, 1], size=n_coppie)               # +1: il proprio sistema e' il migliore
    def sceglie_A(b, proprio_in_a):
        segno = 1 if proprio_in_a else -1
        logit = 2.5 * segno * d + segno * b              # il bonus spinge verso il proprio
        return rng.random(n_coppie) < 1 / (1 + np.exp(-logit))
    def tasso(b):                                        # vince il proprio: sceglie A se sta in A,
        return (sceglie_A(b, True) + ~sceglie_A(b, False)) / 2   # non sceglie A se sta in B
    return tasso(bonus) - tasso(0.0)

def rilevato(scarti):
    """z sulla media degli scarti: il test che faresti davvero sui tuoi dati."""
    sd = scarti.std(ddof=1)
    return sd > 0 and abs(scarti.mean() / (sd / math.sqrt(len(scarti)))) > 1.96

print("giudice proprio meno giudice terzo, tasso di vittoria mediato, 400 prove per riga")
print(f"{'bonus vero':>11}{'n coppie':>10}{'scarto medio':>14}{'rilevato':>10}")
for bonus in (0.0, 0.5, 1.0):
    for n in (14, 140):
        prove = [scarti_per_coppia(bonus, n, s) for s in range(400)]
        print(f"{bonus:>11.1f}{n:>10}{np.mean([p.mean() for p in prove]):>14.3f}"
              f"{np.mean([rilevato(p) for p in prove]):>10.3f}")
giudice proprio meno giudice terzo, tasso di vittoria mediato, 400 prove per riga
 bonus vero  n coppie  scarto medio  rilevato
        0.0        14         0.001     0.005
        0.0       140        -0.001     0.045
        0.5        14         0.020     0.025
        0.5       140         0.020     0.245
        1.0        14         0.047     0.077
        1.0       140         0.047     0.765

Con 14 coppie, l'ampiezza che ho a disposizione, un pregiudizio di 0,5 logit viene rilevato nel 2,5% delle prove e uno di 1,0 nel 7,7%: praticamente mai. Con 140 coppie, 1,0 logit viene visto il 76,5% delle volte, 0,5 logit il 24,5%. Dove il bonus è nullo il test dà un falso positivo il 4,5% delle volte a 140 coppie, in linea con il 5% nominale. Il risultato che riporterei di una misura vera su 14 coppie non è «non c'è preferenza per sé stessi», è «non ne vedo, e con questa numerosità non ne avrei visto nemmeno uno di dimensione media». È la differenza fra assenza di prove e prova di assenza, e si dichiara accanto al numero.

Riproducibilità: il seme non basta a dire che il giudice sia stabile

I miei giudici sono deterministici per costruzione, perché il generatore deriva dalla decisione. Un giudice vero non lo è per default, e la prima mossa è azzerare la temperatura e fissare il seme dove l'API lo permette. Non ho misurato in questa lezione quanto un modello vero vari fra una ripetizione e l'altra, e non pubblico una cifra: è una proprietà del modello, della versione e dell'hardware. Lo schema per misurarla sul tuo giudice è semplice e non è stato eseguito: venti prompt di giudizio, dieci ripetizioni ciascuno, e il conteggio dei prompt con almeno un verdetto di minoranza.

Il determinismo, anche quando c'è, non è stabilità del giudizio. Il giudice di posizione con beta = 1,2 è perfettamente riproducibile e si contraddice comunque su una coppia su cinque nel valore atteso. Riproducibile e affidabile sono due proprietà distinte, e la prima è la più facile da confondere con la seconda.

Le regressioni fra due versioni, e un gate che le vede

L'uso quotidiano di un golden set non è confrontare due sistemi diversi: è confrontare il sistema di ieri con quello di oggi. A e A2 hanno la stessa accuratezza, 22 su 36, e una media non può vederlo, ma i due non si comportano allo stesso modo.

def confronta_versioni(vecchia, nuova, critici):
    migliorati = [c["id"] for c, a, b in zip(CASI, VERO[vecchia], VERO[nuova]) if b and not a]
    regressioni = [c["id"] for c, a, b in zip(CASI, VERO[vecchia], VERO[nuova]) if a and not b]
    media, lo, hi = bootstrap_differenza(VERO[nuova], VERO[vecchia])
    critiche = [i for i in regressioni if i[0] in critici]
    passa = not critiche and lo >= -0.05
    return migliorati, regressioni, media, lo, hi, critiche, passa

for famiglia, etichetta_f in (("R", "con risposta"), ("N", "da astensione")):
    ok = lambda n: sum(v for v, c in zip(VERO[n], CASI) if c["id"][0] == famiglia)
    print(f"famiglia {famiglia} ({etichetta_f}): A {ok('A')}/18  ->  A2 {ok('A2')}/18")
mig, reg, media, lo, hi, critiche, passa = confronta_versioni("A", "A2", critici="R")
print(f"totale: A {sum(VERO['A'])}/36  ->  A2 {sum(VERO['A2'])}/36  (differenza {media:+.3f}, IC 95% [{lo:+.3f}, {hi:+.3f}])")
print(f"migliorati {len(mig)}: {' '.join(mig)}")
print(f"regressioni {len(reg)}: {' '.join(reg)}")
print(f"regressioni su casi critici (famiglia R): {len(critiche)} -> gate {'PASSA' if passa else 'FALLISCE'}")
famiglia R (con risposta): A 8/18  ->  A2 5/18
famiglia N (da astensione): A 14/18  ->  A2 17/18
totale: A 22/36  ->  A2 22/36  (differenza +0.000, IC 95% [-0.139, +0.139])
migliorati 3: N07 N14 N16
regressioni 3: R03 R05 R15
regressioni su casi critici (famiglia R): 3 -> gate FALLISCE

A2 ha migliorato tre casi della famiglia N (N07, N14, N16, dove ora si astiene correttamente) e ne ha peggiorati tre della famiglia R (R03, R05, R15, dove prima rispondeva bene e ora tace). L'intervallo bootstrap della differenza totale è [−0,139; +0,139]: perfettamente centrato. La regola del gate guarda altrove: nessuna regressione sui casi critici e limite inferiore dell'intervallo non sotto −0,05. Qui i casi critici sono la famiglia R, e A2 ne perde tre, quindi il gate fallisce. È la forma più utile di un golden set in produzione: una soglia sulla media lascerebbe passare la versione che ha rotto tre risposte giuste, e la lista dei casi regrediti dice a chi riceve la segnalazione da dove cominciare.

Il costo di un giro di valutazione

Con modelli veri il costo di una valutazione è in secondi e token, e decide se entra in un ciclo di sviluppo o resta un rito trimestrale. Non misuro tempi qui, perché un programma a copione impiega millisecondi e non dice niente su un modello. Misuro invece una cosa che si porta con sé: quante chiamate e quante parole di prompt servono.

prompt_punto = [GIUDICE.format(domanda=c["domanda"], atteso=c["atteso"], risposta=r)
                for c, _, r, _ in TUTTE]
par_parole = [len(p.split()) for p in prompt_punto]
print(f"giro punto per punto: {len(prompt_punto)} chiamate, {sum(par_parole)} parole di prompt "
      f"(mediana {int(statistics.median(par_parole))})")
print(f"giro a coppie nei due ordini: {2 * len(COPPIE)} chiamate")
giro punto per punto: 108 chiamate, 6239 parole di prompt (mediana 54)
giro a coppie nei due ordini: 28 chiamate

Un giro punto per punto sui tre sistemi, 108 risposte, richiede 108 chiamate e circa 6.200 parole di prompt, con una mediana di 54 parole per chiamata; il confronto a coppie nei due ordini ne aggiunge 28. Per convertire le parole in token e in tempo usi il tokenizzatore e la velocità del tuo giudice (la lezione sulla tokenizzazione mostra perché il rapporto cambia da una lingua all'altra). Il conto si moltiplica per i giudici e per gli ordini: tre giudici su un sistema in più fanno tre volte le chiamate. L'ordine giusto è prima il golden set, poi un giudice piccolo, e quello grande solo dove il piccolo non distingue.

Il protocollo che tiene insieme i pezzi

  1. Decidi la differenza minima che vuoi leggere: da quella ricavi n, non il contrario.
  2. Costruisci il golden set e congelalo, con un identificativo per caso, in un file versionato.
  3. Includi i casi in cui la risposta non compare: misurano l'astensione e costano poco.
  4. Etichetta a mano un campione: è la taratura di tutto il resto.
  5. Misura l'accordo del giudice con accuratezza, kappa e quota di «corretta», e guarda gli errori per sistema.
  6. Misura i pregiudizi sul tuo compito: posizione nei due ordini, lunghezza, giudice terzo.
  7. Confronta in modo appaiato, mai due medie in assoluto, e tieni la lista dei casi regrediti.

L'ordine conta, ed è per questo che è un elenco numerato: ogni passo rende leggibile il successivo. I passi 1 e 7 sono quelli che si saltano, e sono i due che decidono se la valutazione produce informazione. Il passo 4 è l'unico che costa fatica umana, e si rifà a ogni cambio di modello o di prompt del giudice: la taratura appartiene alla coppia giudice-prompt, non al giudice.

Trade-off

  • Golden set grande contro golden set curato. La numerosità compra risoluzione, da 0,3735 di ampiezza a venti casi a 0,0567 a mille, ma solo se le etichette restano buone. Mitigazione: cresci a scaglioni, rietichettando un campione a ogni scaglione.
  • Metrica lessicale contro giudice automatico. L'F1 costa microsecondi ed è riproducibile, ma sul sistema che riformula ha dato un accordo di 0,667 contro 0,972 sull'estrattore. Il giudice capisce la riformulazione e porta pregiudizi. Mitigazione: F1 dove la risposta è canonica, giudice dove è prosa, e il kappa misurato per sapere quanto fidarsi.
  • Punto per punto contro confronto a coppie. Il punto per punto dà un numero per sistema e una chiamata per risposta; le coppie sono più facili per il giudice ma costano il doppio delle chiamate e danno solo un ordinamento. Mitigazione: punto per punto per il monitoraggio, coppie per decidere fra due candidati vicini.
  • Aggregato contro per sistema. Il kappa aggregato del giudice a regole è il più alto e il suo ordine dei sistemi è sbagliato. Mitigazione: riporta sempre gli errori scomposti per sistema e per famiglia di casi.
  • Valutare a ogni commit contro valutare bene. Il giro rapido contro le regressioni ha un costo da hook; due ordini, più giudici e le prove di pregiudizio ne costano molte volte tanto. Mitigazione: due regimi dichiarati, il rapido a ogni commit e il completo prima di una decisione.

I trade-off hanno una radice comune: ogni scorciatoia sposta il costo da un luogo visibile (il tempo di etichettatura) a uno invisibile (un errore sistematico che nessuno ha misurato). Nessuna delle cinque mitigazioni elimina il costo; lo rende un numero che puoi leggere e discutere, ed è tutto ciò che si può chiedere a una valutazione.

Le trappole in cui si cade davvero

  1. Confrontare due medie invece dei casi appaiati. Sintomo: due accuratezze che differiscono di pochi punti e una decisione presa comunque. Causa: gli intervalli si sovrappongono, come i miei due da 36 casi. Rimedio: i discordanti, il test esatto e l'intervallo appaiato, letti insieme.
  2. Confronto a coppie in un ordine solo. Sintomo: il verdetto cambia quando scambi i due blocchi nel prompt. Causa: il pregiudizio di posizione. Rimedio: due passate e media; se i ribaltamenti superano di molto il pavimento atteso, cambia giudice invece di mediare più forte.
  3. Dimenticare che nella seconda passata la lettera A indica l'altro sistema. Sintomo: un tasso di ribaltamento vicino a zero e un tasso di vittoria troppo bello. Causa: verdetti contraddittori contati come conferme. Rimedio: lo scambio esplicito nel codice, con un commento.
  4. Riportare l'accuratezza del giudice senza il kappa. Sintomo: «il nostro giudice concorda col 75% dei giudizi umani». Causa: su un insieme sbilanciato quel 75% può venire da un giudice che dice sempre la stessa cosa. Rimedio: accuratezza, kappa e quota della classe positiva, sempre insieme.
  5. Fidarsi del kappa aggregato. Sintomo: il giudice migliore sulla carta ordina male i sistemi. Causa: gli errori sono concentrati su un sistema. Rimedio: accuratezza attribuita a ciascun sistema, confrontata con le etichette.
  6. Un golden set senza casi negativi. Sintomo: accuratezza alta in valutazione e risposte inventate in produzione. Causa: tutte le domande avevano la risposta nel contesto. Rimedio: la famiglia N e il controllo che la risposta giusta non sia finita per caso nel contesto sbagliato.
  7. Cambiare il prompt del giudice e tenere la taratura vecchia. Sintomo: un salto nei numeri dopo una modifica che non riguardava il sistema. Causa: il kappa era misurato con un altro prompt. Rimedio: versiona il prompt del giudice insieme al golden set.
  8. Concludere «nessun pregiudizio» da pochi casi. Sintomo: «abbiamo controllato, il giudice non preferisce sé stesso». Causa: con poche coppie un effetto medio non si vede. Rimedio: dichiara la numerosità e la dimensione minima rilevabile accanto al risultato.

Le prime quattro sono errori di procedura e si evitano con una lista di controllo; le ultime quattro sono errori di interpretazione e si evitano solo dichiarando, accanto a ogni numero, che cosa quel numero era in grado di vedere. Conviene tenerle a portata di mano come domande da rivolgere a chi presenta una valutazione, te compreso.

Rifare l'esperimento

I blocchi di codice di questa pagina, nell'ordine in cui compaiono e senza modifiche, formano un unico programma di circa 530 righe: Python 3.12 o successivo con NumPy e SciPy, nessuna rete, nessun modello. Incollali in un file, eseguilo due volte e confronta le uscite con cmp: devono essere identiche, perché ogni decisione casuale deriva da un seme e da un identificativo di caso. Il blocco con il prompt del giudice è uno schema da adattare ed è stato eseguito solo nella parte che non chiama modelli, cioè il parser dei verdetti.

Cosa portarti via

Adesso sai dimensionare un golden set dalla differenza che vuoi leggere, calcolare l'intervallo di Wilson e il numero di casi per sistema, confrontare due sistemi sugli stessi casi con McNemar e con un bootstrap appaiato senza fidarti di uno solo, e riconoscere una metrica lessicale che punisce chi riformula. Sai tarare un giudice con accuratezza, kappa e quota di «corretta», sai che un kappa alto non garantisce l'ordine giusto dei sistemi, e sai disegnare un esperimento che cerca il pregiudizio di posizione, di verbosità e di preferenza per sé stessi, dichiarando quanto è grande l'effetto che saresti in grado di vedere.

Quello che questa lezione non ti dà è la taglia dei pregiudizi di un modello vero: i giudici erano simulati, e i risultati sul loro comportamento sono proprietà del simulatore. Per quella taglia servono le misure dei lavori citati qui sotto, o le tue, fatte con i tuoi dati. Prima di chiedere a un sistema se è migliorato, chiedi al tuo metro di misura di quanto può sbagliare: quel numero lo sai calcolare, e senza di lui tutti gli altri sono aneddoti. La lezione successiva, sul far girare un modello in locale, porta il problema dove i giudici sono veri: memoria, quantizzazione e latenza.

Fonti

Wilson (1927), McNemar (1947) e Cohen (1960) restano citazioni bibliografiche dei metodi che implemento: non ho riaperto gli articoli originali, e i conti sono verificati dai blocchi eseguiti e dagli esercizi risolti a mano.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti