Volevo un numero onesto per una domanda semplice: se chiedo a un modello quali sono le migliori agenzie SEO in Italia, e glielo richiedo identico trenta volte, quanto cambia la risposta? Il dato che ne è uscito l’ho raccontato altrove, insieme a cosa significa per chi compra o vende SEO. Qui mi interessa la parte noiosa, cioè come si misura una cosa del genere senza raccontarsi balle, perché la prima volta l’ho fatto male e me ne sono accorto solo rileggendo i miei stessi numeri.
Il primo giro era sbagliato, e non per il motivo che pensavo
A luglio avevo fatto la stessa prova con venti run su gpt-4o-mini. Il risultato era ottimo da mostrare: tanti nomi diversi, il primo posto che ballava di continuo. Quando ho ripreso quei numeri per scriverci sopra qualcosa di serio, ne ho trovati due che non reggevano.
Il primo è banale: gpt-4o-mini è il modello piccolo ed economico, quindi era la scelta sbagliata per dire qualcosa sul comportamento di un assistente vero. Ma è il secondo quello interessante, perché è un errore di misura e non di configurazione. Nella lista dei nomi c’erano TSW e Tsw contati come due agenzie diverse. C’erano Search On e Search On Media Group. C’erano SEO Chef e SEOchef.
La metrica che stavo pubblicando era quanti nomi diversi escono. Ogni grafia non normalizzata gonfia esattamente quel numero. Stavo producendo un risultato più impressionante grazie a un difetto del mio codice, il che è il tipo di bias di cui non ti accorgi proprio perché il numero ti piace.
Quindi la prima lezione, che è anche l’unica non ovvia di tutto il lavoro: in una misura come questa la difficoltà non sta nel chiamare l’API, sta nel decidere quando due stringhe sono la stessa entità. Quella decisione non è un dettaglio di pulizia dei dati, è la misura. Il resto è un ciclo for.
Non l’ho risolta con un modello di entity resolution, l’ho risolta a mano: normalizzo maiuscole e spazi, tengo la grafia più frequente come forma canonica, e per i casi che restano decido io e li scrivo in un dizionario di alias dentro lo script, così sono ispezionabili da chiunque legga il codice. Sono venti righe che valgono più di tutto il resto.
Temperatura 1.0 non è un trucco
L’obiezione che mi aspettavo di ricevere è che la varianza me la sono costruita alzando la temperatura. Vale la pena dirlo prima: 1.0 è il valore di default dell’API delle chat completions. Non ho toccato niente, ed è esattamente il punto, perché la domanda a cui volevo rispondere è cosa vede una persona normale, non cosa succede in condizioni di laboratorio.
Mettere temperature=0 sarebbe stato peggio che inutile. Non ti dà la risposta vera, ti dà un singolo percorso reso deterministico, e quel percorso non è più rappresentativo degli altri diciannove. Se misuri la stabilità di un sistema, non puoi prima spegnere la cosa che genera instabilità.
Stesso ragionamento per lo stato: ogni run deve essere una conversazione nuova, senza traccia delle precedenti. Se riusi la stessa sessione stai misurando quanto il modello si ripete guardando il proprio contesto, che è una domanda legittima ma diversa da quella che avevo in mente.
Lo scheletro, che è più corto di quanto sembri
Niente dipendenze, solo la standard library, perché uno script del genere deve poter girare tra sei mesi senza litigare con l’ambiente.
positions = defaultdict(list)
runs = []
for i in range(RUNS):
text = call_openai(model, key) # una conversazione nuova ogni volta
names = [canon(n) for n in parse_list(text)][:5]
runs.append(names)
for pos, name in enumerate(names, start=1):
positions[name].append(pos) # frequenza E posizione, non solo presenza
Due scelte dentro queste righe. La prima è che tengo la posizione e non solo la presenza, perché “esce venti volte su trenta” e “quando esce è quasi sempre quinto” sono due informazioni diverse e la seconda si perde se conti solo le occorrenze. La seconda è che il parsing accetta solo righe di un elenco numerato o puntato: chiedo al modello un formato preciso e scarto quello che non lo rispetta, invece di provare a indovinare i nomi dentro la prosa.
C’è poi una cosa che ho messo apposta e che consiglio a chiunque scriva script di misura: se una run fallisce dopo i suoi retry, lo script muore. Non scrive un dataset con ventinove risposte dicendo trenta, non riempie il buco, non arrotonda. Un numero sbagliato in un file JSON sopravvive alla memoria di chi l’ha generato, e sei mesi dopo te lo ritrovi in una slide senza sapere da dove viene.
La scoperta che non cercavo
Rifatta la misura su gpt-4o con trenta run, ho confrontato per curiosità i due elenchi, il piccolo di luglio e il grande di agosto. Tra le due liste ci sono quattro nomi in comune, su una cinquantina per parte. Non è una classifica che si riordina cambiando modello, è un altro insieme di nomi.
Per chi lavora con questi sistemi la conseguenza pratica è forte: qualunque affermazione del tipo “siamo primi su ChatGPT” senza specificare il modello non vuol dire niente, e il modello non lo sceglie l’utente, glielo assegna la piattaforma in base al piano e al momento.
Il limite del mio test, dichiarato prima che me lo dicano: interrogando l’API senza strumenti di ricerca sto misurando quello che il modello ha in memoria, non quello che l’interfaccia di ChatGPT mostra quando va a cercare sul web. È il sostrato, non lo schermo. È una misura utile e insufficiente insieme, e il giro con la ricerca attiva è la prossima cosa che voglio fare.
I numeri veri, con i nomi delle agenzie e le posizioni di ogni singola risposta, li ho messi nell’articolo su trygeosuite.it, che è il prodotto con cui questa roba la faccio su scala: cinquanta domande invece di una, quattro modelli invece di uno, e la misura ripetuta nel tempo, che poi è l’unica parte che conta davvero.