@ :~/lab/fine-tuning-estrazione-knowledge-graph-lezione-dati$
← cd ~/lab

./run fine-tuning-estrazione-knowledge-graph-lezione-dati

Relation F1 a 0.38: quando la lezione non era il modello, ma i dati

Diario tecnico di un fine-tuning: LoRA su un modello da 27B per estrarre knowledge graph da documenti disordinati. La svolta non è stata un modello più grande, ma ri-annotare i dati.

status
draft
project
crimezero
updated
2026-07-07
tags
#fine-tuning#llm#knowledge graph#lora#vllm#dataset
generate cover # alt: Pila di documenti disordinati che diventa un grafo ordinato di nodi e archi, con un grafico F1 che sale grazie ai dati e non al modello

Questo è il progetto di cui posso raccontare meno nei dettagli, e va bene così: il dominio resta fuori. Ma la lezione tecnica la posso spiegare tutta, e vale per chiunque lavori con gli LLM. In una riga: prima di cambiare modello, guarda i dati.

Il compito

Testo lungo, sporco, in italiano → un knowledge graph in JSON. Ogni documento è spezzato in segmenti; il target di ognuno è un oggetto con questa forma:

  • entities: lista di {name, type} — nodi tipati con nome canonico
  • relations: lista di {subject, predicate, object, evidence_text} — archi diretti tipati, più la citazione testuale che li prova

Con un vincolo duro imposto nello schema: subject e object devono essere esattamente un name presente in entities — il grafo deve essere internamente coerente. Il vocabolario è chiuso: un enum fisso di tipi entità e predicati.

Il modello e il fine-tuning

Parto da un modello base denso da ~27B, e faccio LoRA/SFT (PEFT + TRL). Nessun addestramento da zero: aggiorno lo 0.58% dei parametri.

mods7 = ["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
target = r".*language_model.*\.(" + "|".join(mods7) + r")$"  # salta la vision tower
LoraConfig(r=32, lora_alpha=64, lora_dropout=0.1,
           target_modules=target, bias="none", task_type="CAUSAL_LM")
# bf16, grad-checkpointing, lr=2e-4 cosine, micro_bs=1 x grad_accum=8,
# cutoff 16384 token, ~4 epoche, best su eval_loss + early stopping

Il servizio è vLLM con multi-LoRA: il base resta residente una volta, gli adapter si attaccano come moduli nominati — più fine-tuning condividono lo stesso base. Gira su HPC on-prem (2 GPU classe H200, Slurm), bindato solo su loopback, tracciato in MLflow — dove finiscono solo aggregati, mai testo grezzo o generazioni.

La svolta (che è tutto il progetto)

La v1 l’ho addestrata, congelata e messa in produzione. Funzionava. E poi ho fatto l’errore classico: pensare che per migliorare servisse un modello più grande.

Sbagliato. Guardando dove sbagliava, il quadro era netto:

  • Entità: F1 ~0.68-0.71, decente.
  • Relazioni: F1 ~0.38-0.41, debole.

E la debolezza era concentrata nella coda rara: i predicati con meno di 500 esempi segnavano ~0.00, con una correlazione chiara tra rarità e F1. La causa radice, quando l’ho trovata, era imbarazzante e istruttiva: lo schema ricco era stato aggiunto dopo che il corpus era già annotato. Documenti che erano esempi da manuale di una relazione nuova ne estraevano ~0, perché a mancare non era il testo — era lo strato di annotazione.

Quindi la leva non era “più documenti” (provato: rende ~0). Era ri-annotare il testo esistente sotto lo schema nuovo.

La v2 è ingegneria dei dati, non del modello

Tre mosse, tutte quantificate:

  1. Allargamento schema — nuovi predicati enumerati dai dati, mai inventati.
  2. Ri-annotazione fine — stesso testo, etichette più nette (rimappare il volume di un catch-all grossolano sui figli direzionali precisi).
  3. Densificazione della coda — predicati prima vuoti riempiti con ri-annotazione delta su ~1.800 chunk candidati.

E una disciplina che mi è piaciuta molto: learnable vs measurable come vincolo di split. Il test set è scelto da un selettore greedy deterministico che deve coprire ogni predicato prioritario con ≥30 istanze di test (misurabilità) senza far scendere nessun predicato sotto le 50 in training (apprendibilità). Dove i due obiettivi confliggono, il predicato è dichiarato “apprendibile ma non misurabile su questo split” — tenuto e addestrato, escluso dalla metrica. Non buttato.

Misurare onestamente

  • Micro P/R/F1 separati per entità e relazioni, con matching greedy uno-a-uno.
  • Scoring alias-aware: dà credito se la forma corrisponde a qualsiasi variante nota dell’entità gold, non solo al nome canonico — separa i veri errori dalla semplice varianza di naming (relation F1 0.38→0.41).
  • Due audit oltre l’F1: campiono i falsi positivi e controllo se entrambi gli estremi e la citazione compaiono davvero nel chunk (≈47% degli “errori” erano estrazioni corrette mancanti dal gold — incompletezza dell’annotazione, non del modello); e verifico che l’evidence_text dei veri positivi sia verbatim nel testo (~87.5%, un check anti-allucinazione).

Cosa ho imparato

Il base era già grande, e le sue entità erano forti; scalarlo non avrebbe fabbricato relazioni che il gold non conteneva. Gli audit lo dicevano chiaro: una fetta grossa dei “falsi positivi” erano estrazioni giuste assenti dall’annotazione. È lo stesso pattern del record pubblico (TACRED→Re-TACRED, +16 F1 solo pulendo il gold, architettura invariata).

Poco eccitante da mettere in una slide. Ma è quello che ha spostato l’ago: in un progetto di estrazione, il prodotto non è il modello — è il dataset curato che ci sta dietro. Il modello è sostituibile. Il gold standard costruito bene, no.

Per riservatezza, dominio e contenuti restano fuori dal diario. Racconto il metodo, non la materia.