Salta al contenuto principale

NLP italiano con spaCy e transformers: pipeline pratica

Tokenizzazione, lemmi, entità, dipendenze e classificazione per l'italiano: cinque programmi eseguiti, poi i numeri dichiarati da spaCy e dai BERT italiani.

Cosa ne pensi di questo articolo?

NLP italiano con spaCy e transformers: pipeline pratica
Italian NLPFoto:RDNE Stock projectPexels

In Breve

Tokenizzazione, lemmi, entità, dipendenze e classificazione per l'italiano: cinque programmi eseguiti, poi i numeri dichiarati da spaCy e dai BERT italiani.

Una pipeline di NLP per l'italiano si rompe quasi sempre nello stesso punto: il testo reale contiene apostrofi, clitici, plurali e forme verbali che l'esempio di tutorial non contiene. Per questo qui ogni passaggio della pipeline (tokenizzazione, lemmi e categorie grammaticali, entità, alberi di dipendenza, classificazione) ha un programma che puoi eseguire senza scaricare nessun modello, e solo dopo vedi come lo stesso lavoro lo fanno spaCy e i transformer, con i numeri che i loro autori dichiarano. Il risultato è una mappa di dove si sbaglia e di come te ne accorgi.

Che cosa fanno davvero spaCy e i transformer, e quando scegliere l'uno o gli altri

spaCy è una libreria di pipeline: prende una stringa e la fa passare per componenti in sequenza, ciascuno dei quali aggiunge un'annotazione (confini dei token, categoria grammaticale, lemma, relazioni sintattiche, entità). I modelli italiani di spaCy sono piccole reti addestrate per questi compiti e funzionano su CPU. Un transformer come BERT, invece, produce una rappresentazione numerica di ogni token o frase, su cui costruisci tu un classificatore, un motore di somiglianza o un estrattore di entità. Nel primo caso compri un'annotazione pronta; nel secondo compri un buon punto di partenza che devi completare con i tuoi dati.

La regola pratica è partire dalla pipeline più semplice che risolve il problema. Per estrarre nomi di persone e luoghi da articoli di cronaca, spaCy basta. Per classificare messaggi di clienti in dieci categorie con poche centinaia di esempi, un classificatore lineare su n-grammi di caratteri (vedi più sotto) è un avversario più difficile da battere di quanto si creda. Il transformer entra quando devi distinguere sfumature di significato che le parole da sole non portano, e quando hai dati etichettati per verificarlo.

Tokenizzazione: l'apostrofo e i clitici

La tokenizzazione è il passo che decide che cosa è «una parola», e in italiano split() sbaglia in due modi prevedibili. Il primo è l'elisione: in «L'auto» o «all'università» l'apostrofo unisce l'articolo o la preposizione alla parola successiva, e un conteggio per spazi le tratta come una sola parola, che poi non troverai mai nel dizionario. Il secondo sono i punti e i segni di punteggiatura attaccati alle parole, che trasformano «rotta.» e «rotta» in due voci diverse.

Il programma sotto risolve entrambi con un'espressione regolare e aggiunge un secondo livello, la scomposizione dei clitici. L'espressione ha le alternative in ordine di priorità: prima l'eccezione «po'» (che è una parola intera), poi la sequenza «lettere + apostrofo seguito da una lettera» che isola l'elisione, poi la parola, il numero e il singolo simbolo. Il punto resta attaccato solo alle abbreviazioni dell'elenco ABBREVIAZIONI, altrimenti il punto finale di frase si fonde con l'ultima parola.

import re

TESTI = [
    "L'auto di Giuseppe è rotta.",
    "Il prof. Bianchi insegna all'università di Roma, un po' tardi.",
    "Vediamoli domani; portamelo subito e mandaglielo.",
]

ABBREVIAZIONI = {"prof", "ing", "dott", "sig"}   # il punto resta attaccato solo a queste
LETTERE = "A-Za-zÀ-ÿ"
# Ordine delle alternative = ordine di priorita': "po'" intero, poi elisione (l', dell'),
# poi parola, poi numero, poi un singolo simbolo.
TOKEN = re.compile(rf"po'|[{LETTERE}]+'(?=[{LETTERE}])|[{LETTERE}]+|\d+|[^\s{LETTERE}\d]")

def tokenizza(testo):
    out, pos = [], 0
    for m in TOKEN.finditer(testo):
        tok = m.group(0)
        fine = m.end()
        if tok.lower() in ABBREVIAZIONI and testo[fine:fine + 1] == ".":
            tok += "."
        elif tok == "." and out and out[-1].endswith("."):
            continue
        out.append(tok)
    return out

# Clitici, dal piu' lungo al piu' corto: "glie" deve battere "gli" e "li".
CLITICI = ["glie", "gli", "mi", "ti", "ci", "vi", "si", "me", "te", "ce", "ve", "se",
           "lo", "la", "li", "le", "ne"]
# Senza un lessico di verbi non si distingue "portamelo" da una parola qualunque.
BASI = {"vediamo", "porta", "manda"}

def scomponi_clitici(parola):
    minuscola = parola.lower()
    for base in sorted(BASI, key=len, reverse=True):
        if minuscola.startswith(base) and len(minuscola) > len(base):
            resto, pezzi = minuscola[len(base):], []
            while resto:
                for c in sorted(CLITICI, key=len, reverse=True):
                    if resto.startswith(c):
                        pezzi.append(c)
                        resto = resto[len(c):]
                        break
                else:
                    return [parola]     # il resto non e' fatto di clitici: parola intatta
            return [base] + pezzi
    return [parola]

for t in TESTI:
    nostro = tokenizza(t)
    print("input   :", t)
    print("split() :", t.split())
    print("regex   :", nostro)
    print("clitici :", [p for w in nostro for p in scomponi_clitici(w)])
    print()

# Output:
# input   : L'auto di Giuseppe è rotta.
# split() : ["L'auto", 'di', 'Giuseppe', 'è', 'rotta.']
# regex   : ["L'", 'auto', 'di', 'Giuseppe', 'è', 'rotta', '.']
# clitici : ["L'", 'auto', 'di', 'Giuseppe', 'è', 'rotta', '.']
#
# input   : Il prof. Bianchi insegna all'università di Roma, un po' tardi.
# split() : ['Il', 'prof.', 'Bianchi', 'insegna', "all'università", 'di', 'Roma,', 'un', "po'", 'tardi.']
# regex   : ['Il', 'prof.', 'Bianchi', 'insegna', "all'", 'università', 'di', 'Roma', ',', 'un', "po'", 'tardi', '.']
# clitici : ['Il', 'prof.', 'Bianchi', 'insegna', "all'", 'università', 'di', 'Roma', ',', 'un', "po'", 'tardi', '.']
#
# input   : Vediamoli domani; portamelo subito e mandaglielo.
# split() : ['Vediamoli', 'domani;', 'portamelo', 'subito', 'e', 'mandaglielo.']
# regex   : ['Vediamoli', 'domani', ';', 'portamelo', 'subito', 'e', 'mandaglielo', '.']
# clitici : ['vediamo', 'li', 'domani', ';', 'porta', 'me', 'lo', 'subito', 'e', 'manda', 'glie', 'lo', '.']

Nella terza frase la scomposizione dei clitici separa «portamelo» in porta + me + lo e «mandaglielo» in manda + glie + lo, mentre «Vediamoli» diventa vediamo + li grazie al confronto in minuscolo. La condizione che tiene tutto in piedi è l'elenco BASI: senza un lessico di forme verbali non puoi distinguere «portamelo» da una parola che per caso finisce in «lo», e la funzione restituisce la parola intatta se il resto non è fatto di clitici. Il caso difficile, «dammelo», non è nel programma perché l'imperativo monosillabico raddoppia la consonante (da' + mi diventa «dammi»): serve una regola in più, o un modello addestrato. Questo è il motivo per cui in produzione si usa il tokenizzatore di una libreria e si controllano a campione i casi del proprio dominio.

Categorie grammaticali e lemmi: perché il lemma dipende dalla categoria

Un lemmatizzatore ridotto a un dizionario «forma → lemma» sbaglia ogni volta che una forma ha due categorie. «Porta» è il sostantivo di una casa (lemma «porta») o la terza persona del verbo «portare»; «sale» è il condimento o il verbo «salire»; «la» è un articolo (lemma «il») o un pronome (lemma «la»). Il lemma giusto si sceglie dopo aver scelto la categoria, ed è così che lavorano le pipeline: il tagger prima, il lemmatizzatore poi.

Per vedere come il tagger risolve l'ambiguità, il programma sotto implementa il modello più semplice che lo fa: un modello di Markov nascosto. Impara due tabelle contando su un mini corpus annotato a mano: la probabilità che una categoria emetta una certa parola, e la probabilità che una categoria segua un'altra. L'algoritmo di Viterbi sceglie poi la sequenza di categorie più probabile per tutta la frase. È la ragione per cui «la porta è chiusa» (articolo + sostantivo) e «Maria la porta in casa» (pronome + verbo) ricevono etichette diverse per la stessa parola «porta».

import math
from collections import Counter, defaultdict

# Mini corpus annotato a mano (tag stile Universal Dependencies). Non e' un corpus vero:
# serve a mostrare il meccanismo, non a misurare l'accuratezza.
CORPUS = [
    "la/DET porta/NOUN è/AUX aperta/ADJ",
    "la/DET porta/NOUN della/ADP casa/NOUN è/AUX chiusa/ADJ",
    "la/PRON porta/VERB in/ADP casa/NOUN",
    "la/PRON vedo/VERB ogni/DET mattina/NOUN",
    "Maria/PROPN la/PRON porta/VERB a/ADP scuola/NOUN",
    "il/DET sale/NOUN è/AUX bianco/ADJ",
    "il/DET sale/NOUN costa/VERB poco/ADV",
    "il/DET gatto/NOUN sale/VERB sul/ADP tetto/NOUN",
    "il/DET gatto/NOUN nero/ADJ sale/VERB sul/ADP muro/NOUN",
    "le/DET ragazze/NOUN correvano/VERB verso/ADP la/DET scuola/NOUN",
    "la/DET scuola/NOUN è/AUX vecchia/ADJ",
]
LEMMI = {  # (forma, tag) -> lemma: il tag e' cio' che disambigua
    ("porta", "NOUN"): "porta", ("porta", "VERB"): "portare",
    ("sale", "NOUN"): "sale", ("sale", "VERB"): "salire",
    ("la", "DET"): "il", ("la", "PRON"): "la", ("correvano", "VERB"): "correre",
    ("è", "AUX"): "essere", ("le", "DET"): "il", ("ragazze", "NOUN"): "ragazza",
}

emiss, trans, tags = defaultdict(Counter), defaultdict(Counter), set()
for frase in CORPUS:
    prec = "<s>"
    for coppia in frase.split():
        parola, tag = coppia.rsplit("/", 1)
        emiss[tag][parola.lower()] += 1
        trans[prec][tag] += 1
        tags.add(tag); prec = tag
    trans[prec]["</s>"] += 1

V = len({p for t in emiss.values() for p in t}) + 1   # +1 per le parole sconosciute
def lp_em(tag, parola):   # add-one: una parola mai vista non azzera la probabilita'
    return math.log((emiss[tag][parola] + 1) / (sum(emiss[tag].values()) + V))
def lp_tr(a, b):
    return math.log((trans[a][b] + 1) / (sum(trans[a].values()) + len(tags) + 1))

def viterbi(parole):
    parole = [p.lower() for p in parole]
    best = [{t: (lp_tr("<s>", t) + lp_em(t, parole[0]), None) for t in sorted(tags)}]
    for p in parole[1:]:
        riga = {}
        for t in sorted(tags):
            punteggio, da = max((best[-1][a][0] + lp_tr(a, t), a) for a in sorted(tags))
            riga[t] = (punteggio + lp_em(t, p), da)
        best.append(riga)
    t = max(sorted(tags), key=lambda x: best[-1][x][0] + lp_tr(x, "</s>"))
    out = [t]
    for riga in reversed(best[1:]):
        t = riga[t][1]; out.append(t)
    return list(reversed(out))

for frase in ["La porta è chiusa", "Maria la porta in casa", "Il gatto sale sul muro", "Il sale costa poco"]:
    parole = frase.split()
    tg = viterbi(parole)
    righe = [(p, t, LEMMI.get((p.lower(), t), "-")) for p, t in zip(parole, tg)]
    print(frase)
    for p, t, l in righe:
        print(f"   {p:8} {t:6} {l}")

# Output:
# La porta è chiusa
#    La       DET    il
#    porta    NOUN   porta
#    è        AUX    essere
#    chiusa   ADJ    -
# Maria la porta in casa
#    Maria    PROPN  -
#    la       PRON   la
#    porta    VERB   portare
#    in       ADP    -
#    casa     NOUN   -
# Il gatto sale sul muro
#    Il       DET    -
#    gatto    NOUN   -
#    sale     VERB   salire
#    sul      ADP    -
#    muro     NOUN   -
# Il sale costa poco
#    Il       DET    -
#    sale     NOUN   sale
#    costa    AUX    -
#    poco     ADJ    -

Le prime tre frasi vengono etichettate come ci si aspetta e i lemmi vengono dal lessico LEMMI con chiave (forma, categoria): «porta» dà «porta» come sostantivo e «portare» come verbo. L'ultima frase mostra il limite onesto del metodo. «Costa» è etichettata come ausiliare e «poco» come aggettivo, benché nel corpus compaiano come verbo e avverbio: con undici frasi, le transizioni (dopo un sostantivo, l'ausiliare è frequente) pesano più dell'unica osservazione della parola. È lo stesso errore che fa un modello vero davanti a un dominio che non ha visto, con la differenza che il modello vero ha letto centinaia di migliaia di frasi.

Entità nominate: come si misura se funzionano

Una entità nominata è un frammento di testo che indica una persona, un luogo, un'organizzazione. Il modo più semplice di riconoscerle è una lista di nomi noti, ed è un buon modo per capire come si valuta un riconoscitore, che è ciò che conta quando devi scegliere tra un modello e l'altro. La regola standard è severa: un'entità prevista è giusta solo se coincidono inizio, fine e tipo. Un frammento che prende solo «Giuseppe» invece di «Giuseppe Verdi» non vale mezzo punto: vale un falso positivo e un'entità mancata.

# NER minimale (maiuscole + liste di nomi) e valutazione a livello di entita'.
# Un'entita' e' corretta solo se coincidono inizio, fine E tipo: e' la regola
# con cui si calcolano precisione e recupero dei modelli veri.
NOMI_PROPRI = {"Giuseppe": "PER", "Maria": "PER", "Bianchi": "PER", "Rossi": "PER",
               "Roma": "LOC", "Firenze": "LOC", "Parigi": "LOC", "Italia": "LOC",
               "Fiat": "ORG"}

def predici(parole):
    """Span (inizio, fine_esclusa, tipo): nomi noti consecutivi dello stesso tipo si fondono."""
    span, i = [], 0
    while i < len(parole):
        if parole[i] not in NOMI_PROPRI:
            i += 1
            continue
        tipo, j = NOMI_PROPRI[parole[i]], i + 1
        while j < len(parole) and NOMI_PROPRI.get(parole[j]) == tipo:
            j += 1                       # "Maria Rossi" = una sola persona
        span.append((i, j, tipo))
        i = j
    return span

# (frase, entita' vere scritte a mano)
GOLD = [
    ("Maria Rossi lavora a Firenze".split(), {(0, 2, "PER"), (4, 5, "LOC")}),
    ("Giuseppe Verdi nacque vicino a Parma".split(), {(0, 2, "PER"), (5, 6, "LOC")}),
    ("La Fiat ha chiuso lo stabilimento di Roma".split(), {(1, 2, "ORG"), (7, 8, "LOC")}),
    ("Il Consiglio di Stato ha deciso".split(), {(1, 4, "ORG")}),
    ("Il prof. Bianchi insegna a Parigi".split(), {(2, 3, "PER"), (5, 6, "LOC")}),
]

vp = fp = fn = 0
for parole, gold in GOLD:
    pred = set(predici(parole))
    vp += len(pred & gold); fp += len(pred - gold); fn += len(gold - pred)
    print(" ".join(parole))
    print("   mancate :", sorted(gold - pred))
    print("   spurie  :", sorted(pred - gold))
p, r = vp / (vp + fp), vp / (vp + fn)
f1 = 2 * p * r / (p + r)
print(f"\nvp={vp} fp={fp} fn={fn}  precisione={p:.2f} recupero={r:.2f} F1={f1:.2f}")

# Output:
# Maria Rossi lavora a Firenze
#    mancate : []
#    spurie  : []
# Giuseppe Verdi nacque vicino a Parma
#    mancate : [(0, 2, 'PER'), (5, 6, 'LOC')]
#    spurie  : [(0, 1, 'PER')]
# La Fiat ha chiuso lo stabilimento di Roma
#    mancate : []
#    spurie  : []
# Il Consiglio di Stato ha deciso
#    mancate : [(1, 4, 'ORG')]
#    spurie  : []
# Il prof. Bianchi insegna a Parigi
#    mancate : []
#    spurie  : []
#
# vp=6 fp=1 fn=3  precisione=0.86 recupero=0.67 F1=0.75

Il programma fonde i nomi consecutivi dello stesso tipo (Maria Rossi diventa un solo span), e l'uscita dà i tre numeri con cui si confrontano i riconoscitori: precisione 0,86 (quante entità previste sono giuste), recupero 0,67 (quante entità vere sono state trovate) e F1 0,75, che li riassume. Gli errori hanno due nature diverse. «Parma» è semplicemente fuori dalla lista (mancata), mentre «Giuseppe Verdi» produce uno span parziale che conta due volte, e «Consiglio di Stato» non è riconosciuto perché nessuna delle sue parole è nella lista. Sono esattamente i casi su cui un modello statistico guadagna sulle liste, e i casi da guardare quando ne valuti uno sui tuoi testi.

Dipendenze sintattiche: da un albero a una tripla

Il parsing a dipendenze assegna a ogni parola una testa e una relazione (soggetto, oggetto, modificatore) secondo lo schema Universal Dependencies. Il risultato è un albero e serve a rispondere a domande come «chi fa che cosa a chi?». Il programma sotto parte da un albero scritto a mano nel formato che un parser restituisce (indice, testo, indice della testa, relazione) ed estrae la tripla soggetto-verbo-oggetto, aggiungendo i determinanti alla testa del sintagma.

# Dall'albero di dipendenze alle triple soggetto-verbo-oggetto.
# L'albero e' scritto a mano nel formato che un parser Universal Dependencies restituisce:
# per ogni parola (indice, testo, indice della testa, relazione). La radice ha testa = se stessa.
FRASE = [
    (0, "Il", 1, "det"), (1, "gatto", 2, "nsubj"), (2, "mangia", 2, "root"),
    (3, "il", 4, "det"), (4, "pesce", 2, "obj"), (5, "in", 7, "case"),
    (6, "una", 7, "det"), (7, "cucina", 2, "obl"),
]

def sintagma(parole, i):
    """La testa piu' i suoi determinanti e modificatori diretti, in ordine di frase."""
    idx = sorted([i] + [t[0] for t in parole if t[2] == i and t[0] != i and t[3] in ("det", "amod", "case")])
    return " ".join(parole[k][1] for k in idx)

def triple(parole):
    out = []
    for i, testo, testa, rel in parole:
        if rel != "root":
            continue
        soggetti = [t[0] for t in parole if t[2] == i and t[3] == "nsubj"]
        oggetti = [t[0] for t in parole if t[2] == i and t[3] == "obj"]
        out += [(sintagma(parole, s), testo, sintagma(parole, o)) for s in soggetti for o in oggetti]
    return out

print(triple(FRASE))
# Il complemento "in una cucina" ha relazione obl, non obj: non entra nella tripla.
print([sintagma(FRASE, t[0]) for t in FRASE if t[3] == "obl"])

# Output:
# [('Il gatto', 'mangia', 'il pesce')]
# ['in una cucina']

L'albero è scritto a mano perché qui non gira nessun parser, ma il formato è lo stesso che spaCy espone con token.i, token.head.i e token.dep_. La funzione triple cerca la radice, raccoglie i figli con relazione nsubj e obj e ignora il complemento «in una cucina», che ha relazione obl e non è un oggetto diretto. Quando usi un parser vero, il lavoro di ingegneria è proprio questo: decidere quali relazioni contano per il tuo problema e che cosa fare delle frasi passive o con soggetto sottinteso, frequentissime in italiano.

Classificare testi: parole contro n-grammi di caratteri

Qui il problema è l'opposto del parsing: non ti serve capire la frase, ti serve attribuirla a una categoria. L'italiano rende difficile il modello «una colonna per parola», perché una stessa idea compare in molte forme: «vincere», «vinse», «vincevano», «vincitori». Con le parole intere come tratti, «calciatori» e «calcio» sono colonne diverse; con gli n-grammi di caratteri condividono «calci». Il programma sotto confronta le due rappresentazioni sullo stesso classificatore lineare (LinearSVC, con seme fisso), addestrato su 30 frasi scritte a mano e provato su 9 frasi in cui le parole chiave sono flesse in modo diverso da quelle viste.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import make_pipeline
from sklearn.svm import LinearSVC

# Corpus scritto a mano: 3 classi, 10 frasi di addestramento per classe.
TRAIN = {
    "sport": [
        "La squadra ha vinto la partita con un gol nel secondo tempo",
        "L'allenatore ha schierato un attaccante veloce contro la difesa avversaria",
        "Il campionato di calcio riparte con il derby della capitale",
        "La tennista ha battuto la rivale in tre set",
        "Il portiere ha parato un rigore decisivo",
        "I tifosi hanno riempito lo stadio per la finale",
        "Il ciclista ha vinto la tappa in salita",
        "La nazionale ha convocato venti giocatori per il torneo",
        "Il pilota ha conquistato la pole position al gran premio",
        "La squadra di pallavolo ha perso la semifinale",
    ],
    "politica": [
        "Il governo ha presentato una nuova legge in Parlamento",
        "Il ministro ha annunciato la riforma del lavoro",
        "L'opposizione ha votato contro la manovra finanziaria",
        "Il presidente del Consiglio ha incontrato i sindacati",
        "Le elezioni regionali si terranno in autunno",
        "La Camera ha approvato il decreto con la fiducia",
        "Il senatore ha presentato un emendamento alla riforma",
        "I partiti della maggioranza discutono la candidatura del sindaco",
        "Il ministro dell'interno ha firmato una circolare",
        "La coalizione ha perso il voto sulla legge elettorale",
    ],
    "tecnologia": [
        "L'azienda ha lanciato un nuovo processore per smartphone",
        "Gli sviluppatori hanno rilasciato un aggiornamento del sistema operativo",
        "Il nuovo software usa l'intelligenza artificiale per tradurre i testi",
        "La startup ha progettato un chip a basso consumo",
        "Il laptop ha una batteria che dura venti ore",
        "I ricercatori hanno pubblicato un algoritmo per riconoscere le immagini",
        "L'applicazione per telefono permette di pagare con il cellulare",
        "Il produttore di schede video presenta una nuova generazione di processori",
        "La rete cinque gi è arrivata in altre cento città",
        "Il programmatore ha corretto un errore nel codice del server",
    ],
}
# Test: parole flesse in modo DIVERSO da quelle viste (vincere/vinse, giocare, votare, ...).
TEST = [
    ("Gli atleti vincevano le gare e le squadre pareggiavano", "sport"),
    ("I calciatori segnavano e i portieri parando i rigori", "sport"),
    ("I tennisti giocheranno i tornei sui campi in terra", "sport"),
    ("I ministri votarono le leggi proposte dai parlamentari", "politica"),
    ("Gli elettori sceglieranno i sindaci con le elezioni", "politica"),
    ("I governi presentavano decreti e i partiti discutevano", "politica"),
    ("Gli algoritmi riconoscono le immagini dei telefoni", "tecnologia"),
    ("Le applicazioni usano processori e batterie diverse", "tecnologia"),
    ("I programmatori correggevano gli errori dei server", "tecnologia"),
]
X = [f for frasi in TRAIN.values() for f in frasi]
y = [c for c, frasi in TRAIN.items() for _ in frasi]
Xt, yt = [f for f, _ in TEST], [c for _, c in TEST]

CONFIGURAZIONI = {
    "parole":           TfidfVectorizer(lowercase=True),
    "n-grammi di carattere (3-5)": TfidfVectorizer(lowercase=True, analyzer="char_wb", ngram_range=(3, 5)),
}
for nome, vect in CONFIGURAZIONI.items():
    modello = make_pipeline(vect, LinearSVC(random_state=0))
    modello.fit(X, y)
    pred = modello.predict(Xt)
    giuste = sum(p == t for p, t in zip(pred, yt))
    print(f"{nome:30} {giuste}/{len(yt)} corrette; errori:", [(Xt[i][:30], str(pred[i])) for i in range(len(yt)) if pred[i] != yt[i]])

# Output:
# parole                         7/9 corrette; errori: [('Gli atleti vincevano le gare e', 'tecnologia'), ('I calciatori segnavano e i por', 'tecnologia')]
# n-grammi di carattere (3-5)    9/9 corrette; errori: []

La rappresentazione per parole sbaglia due frasi su nove, quella per n-grammi di caratteri nessuna: le frasi sulle «squadre» e sui «calciatori» sono state attribuite alla tecnologia perché le forme esatte non erano nell'addestramento. Leggi il risultato con prudenza, perché nove frasi sono un esperimento, non un benchmark: la differenza di due frasi può cambiare con un'altra scelta di esempi. Quello che il test dimostra è il meccanismo, ed è una buona ragione per provare gli n-grammi di caratteri, o per lemmatizzare prima, quando i dati sono pochi e la lingua è ricca di flessioni. Lo stesso programma, con altre etichette, serve per il sentimento di recensioni: serve però un insieme di frasi etichettate in italiano, che nessun modello generico sostituisce.

Che cosa dichiarano i modelli veri

I numeri dei modelli di spaCy per l'italiano si leggono nei file di metadati pubblicati nel repository explosion/spacy-models, che ho aperto il 2026-11-12. Per la versione 3.8.0 le tre taglie sono diverse per dimensione, non per componenti:

  • it_core_news_sm: 12 MB, F1 sulle entità 0,856, LAS 0,859 sulle dipendenze, accuratezza dei lemmi 0,972;
  • it_core_news_md: 40 MB, F1 entità 0,874, LAS 0,874, lemmi 0,973;
  • it_core_news_lg: 541 MB, F1 entità 0,885, LAS 0,876, UAS 0,911, lemmi 0,975, categoria grammaticale 0,974.

Questi numeri smentiscono due idee diffuse. La prima è che il modello «lg» sia molto più accurato: la differenza sulle entità tra piccolo e grande è di circa 3 punti di F1 e sulle dipendenze di meno di 2 punti, per una dimensione 45 volte maggiore (i metadati indicano che il modello grande include 500000 vettori di parole da 300 dimensioni, il medio 20000 e il piccolo nessuno). La seconda riguarda le entità: l'etichettatura prevede quattro tipi (PER, LOC, ORG, MISC), non dieci. Date, importi e prodotti non hanno un tipo: per estrarli servono regole o un modello tuo. C'è poi un vincolo legale: i metadati dichiarano la licenza CC BY-NC-SA 3.0, che esclude l'uso commerciale; se il tuo progetto è commerciale, verifica la licenza con chi sa farlo prima di adottare il modello.

Per usarli, l'installazione è questa (non eseguita su questa macchina, dove non sono stati scaricati modelli):

pip install spacy
python -m spacy download it_core_news_lg

Il secondo comando scarica il pacchetto del modello dal repository del progetto; la versione del modello deve essere compatibile con la versione di spaCy installata (per il modello 3.8.0 i metadati indicano spaCy da 3.8.0 a meno di 3.9.0), altrimenti il caricamento avvisa o fallisce. Dopo il download, l'uso è quello del blocco seguente: schema da adattare, non eseguito qui.

import spacy

nlp = spacy.load("it_core_news_lg")
doc = nlp("Maria Rossi lavora a Firenze da dieci anni.")

for ent in doc.ents:                      # entità: testo e tipo (PER, LOC, ORG, MISC)
    print(ent.text, ent.label_)
for t in doc:                             # un token per riga: categoria, lemma, relazione, testa
    print(t.text, t.pos_, t.lemma_, t.dep_, t.head.text)

Il blocco usa le stesse quattro informazioni che i programmi sopra hanno costruito a mano: doc.ents per le entità, t.pos_ e t.lemma_ per categoria e lemma, t.dep_ e t.head per l'albero. Per sapere quali componenti sono attivi in un modello caricato guarda nlp.pipe_names: se ne togli uno che non ti serve (per esempio il parser, quando vuoi solo le entità) la pipeline va più veloce. Non ti do tempi, perché dipendono dalla macchina e dal testo: misurali sui tuoi documenti.

I transformer per l'italiano: rappresentazioni, non annotazioni

I modelli italiani più citati sono BERT di dbmdz e UmBERTo. La scheda di dbmdz/bert-base-italian-xxl-cased dichiara licenza MIT e un corpus di addestramento di 81 GB, 13,1 miliardi di token (Wikipedia, OPUS e la parte italiana di OSCAR), e avverte che la dimensione del vocabolario in config.json non coincide con quella reale (31102). UmBERTo, che la scheda descrive come un modello basato su RoBERTa con tokenizzazione SentencePiece (vocabolario da 32 mila voci), è addestrato sulla parte italiana di OSCAR deduplicata (70 GB), e la scheda riporta F1 92,5 sul riconoscimento di entità WikiNER-ITA e accuratezza 99,0 sulle categorie grammaticali di UD Italian ISDT. Sono numeri degli autori, su dataset specifici, non una promessa per i tuoi testi.

import torch
from transformers import AutoTokenizer, AutoModel

nome = "dbmdz/bert-base-italian-xxl-cased"
tok = AutoTokenizer.from_pretrained(nome)
modello = AutoModel.from_pretrained(nome).eval()

frasi = ["La qualità del servizio è eccellente.", "Pessimo servizio, non torno più."]
batch = tok(frasi, padding=True, truncation=True, return_tensors="pt")
with torch.no_grad():
    h = modello(**batch).last_hidden_state          # (frasi, token, dimensione nascosta)
maschera = batch["attention_mask"].unsqueeze(-1)   # 1 sui token veri, 0 sul riempimento
embedding = (h * maschera).sum(1) / maschera.sum(1)  # media dei soli token veri

Schema da adattare, non eseguito qui: non c'è PyTorch su questa macchina e nessun modello è stato scaricato. La riga che conta è l'ultima, la media pesata dalla maschera: calcolare la media su tutti i token, riempimento incluso, mescola nel vettore la lunghezza del lotto in cui capita la frase. Prendere il solo vettore del primo token (il «CLS») è l'abitudine dei tutorial, ma su un BERT non addestrato per la somiglianza dà in genere rappresentazioni mediocri; per cercare frasi simili si usano modelli addestrati allo scopo, come quelli di RAG in italiano: embedding multilingue e recupero semantico. Se invece vuoi adattare il transformer al tuo compito, il percorso è in Fine-tuning di un LLM per la lingua italiana: LoRA, dataset e valutazione.

Domande frequenti

Quale modello spaCy italiano conviene scegliere?

Parti da it_core_news_sm se la CPU o la memoria contano, e passa a quello grande solo se una misura sui tuoi testi giustifica 541 MB: i metadati dichiarano per le entità 0,856 di F1 contro 0,885. Controlla prima la licenza (CC BY-NC-SA 3.0), perché esclude l'uso commerciale.

Come gestisco le parole fuori vocabolario?

I transformer spezzano le parole rare in pezzi di parola noti, quindi non incontrano mai una parola sconosciuta, ma la rappresentano peggio. Un classificatore con n-grammi di caratteri ha lo stesso vantaggio in versione semplice, come mostra il confronto sopra. Per i nomi di dominio (codici prodotto, sigle) conviene una regola esplicita.

Quanto sono affidabili i lemmi di spaCy per l'italiano?

I metadati del modello grande dichiarano un'accuratezza dei lemmi di 0,975 sul test del proprio corpus. Il resto dipende dal dominio: sui testi di legge o di medicina leggi un campione a mano prima di fidarti dei lemmi per un indice di ricerca.

Dove trovo dati italiani etichettati?

Per le dipendenze e le categorie grammaticali il riferimento sono i treebank di Universal Dependencies per l'italiano (ISDT, ParTUT e altri); i dati di entità che spaCy ha usato per il suo modello sono WikiNER. Per i tuoi compiti (reclami, ticket, contratti) i dati migliori sono quelli che etichetti tu, anche solo in poche centinaia.

Che cosa sai fare adesso

Sai spiegare perché in italiano un conteggio per spazi sbaglia e dove finisce il lavoro di un tokenizzatore, sai perché il lemma dipende dalla categoria, sai misurare un riconoscitore di entità con precisione, recupero e F1 a livello di span, e sai quando una pipeline di n-grammi di caratteri batte le parole intere. Sai anche leggere i numeri veri dei modelli (dimensioni, F1, licenza) invece delle cifre rotonde dei tutorial. Il passo successivo è prendere cinquanta frasi dal tuo dominio, etichettarle e far girare i programmi sopra con quelle. Per il livello dei modelli generativi che stanno sopra a queste basi, parti da Landscape degli LLM italiani nel 2025: modelli, ecosistema e scelte pratiche e dalla panoramica dell'anno prima, LLM italiani nel 2024: Minerva, LLaMAntino e i primi modelli nativi.

Fonti

I cinque programmi sono stati eseguiti con Python 3.14 e scikit-learn 1.8.0 (l'ultimo) e le uscite riportate sono identiche in due esecuzioni consecutive. I tre blocchi sulla installazione di spaCy, sull'uso del modello e sui transformer non sono stati eseguiti: nessun modello è stato scaricato.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

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