NLP Fondamenti: Tokenizzazione, Embeddings e la Pipeline Moderna
Ogni volta che chiedi qualcosa a un assistente vocale, traduci un testo con Google Translate, filtri lo spam nella tua casella email o leggi i sottotitoli automatici di un video, stai usando il Natural Language Processing (NLP). Questa disciplina, all'intersezione tra linguistica, informatica e intelligenza artificiale, si occupa di insegnare alle macchine a comprendere, interpretare e generare il linguaggio umano.
Negli ultimi anni, il campo dell'NLP ha subito una trasformazione radicale. Siamo passati da regole scritte a mano e dizionari statici a modelli neurali capaci di comprendere sfumature, contesto e persino ironia. BERT, GPT, LLaMA e i modelli di nuova generazione non sarebbero possibili senza i fondamenti che esploreremo in questo articolo: la tokenizzazione, gli embeddings e la pipeline NLP moderna.
Questo e il primo articolo della serie NLP Moderno: da BERT ai LLM. Partiremo dalle basi assolute, costruendo passo dopo passo le intuizioni necessarie per comprendere i modelli di linguaggio più avanzati. Dedicheremo particolare attenzione alle secificità della lingua italiana, spesso trascurate nelle risorse anglofone.
Cosa Imparerai
- Cos'è l'NLP e perchè e alla base di quasi ogni applicazione AI moderna
- Come il testo viene preprocessato: lowercasing, stopwords, stemming e lemmatization
- I diversi approcci alla tokenizzazione: word-level, character-level e subword (BPE, WordPiece, SentencePiece)
- Rappresentazioni classiche del testo: Bag of Words e TF-IDF
- Word embeddings: Word2Vec, GloVe e l'intuizione geometrica del significato
- Embeddings contestuali: da rappresentazioni statiche a BERT
- Sentence embeddings e le loro applicazioni pratiche
- La pipeline NLP moderna: dal testo grezzo alla predizione
- Specificità del preprocessing per la lingua italiana
- Un esempio end-to-end completo con codice Python
Panoramica della Serie
| # | Articolo | Focus |
|---|---|---|
| 1 | Sei qui - Fondamenti NLP | Tokenizzazione, Embeddings, Pipeline |
| 2 | BERT e Transformer | Architettura Attention, Pre-training |
| 3 | Sentiment Analysis | Classificazione del testo con BERT |
| 4 | Named Entity Recognition | Estrazione di entità dal testo |
| 5 | HuggingFace Transformers | Libreria e modelli pre-addestrati |
| 6 | Fine-Tuning di Modelli | Adattare BERT al tuo dominio |
| 7 | NLP per l'Italiano | Modelli e risorse per la lingua italiana |
| 8 | Da BERT ai LLM | GPT, LLaMA e la generazione di testo |
1. Preprocessing del Testo: Preparare i Dati
Prima che qualsiasi modello NLP possa lavorare con il testo, questo deve essere pulito e normalizzato. Il testo grezzo e pieno di rumore: punteggiatura, maiuscole, abbreviazioni, emoji, HTML, URL. Il preprocessing trasforma questo caos in un formato strutturato e coerente.
1.1 Lowercasing e Normalizzazione
Il primo passo e convertire tutto il testo in minuscolo. Per un computer, "Casa", "casa" e "CASA" sono tre stringhe completamente diverse. Il lowercasing le unifica.
import re
import unicodedata
def normalize_text(text: str) -> str:
"""Normalizzazione base del testo."""
# Lowercasing
text = text.lower()
# Rimozione accenti (opzionale, NON consigliato per italiano)
# text = unicodedata.normalize('NFKD', text)
# Rimozione punteggiatura
text = re.sub(r'[^\w\s]', '', text)
# Rimozione spazi multipli
text = re.sub(r'\s+', ' ', text).strip()
return text
testo = "L'NLP e FANTASTICO! Analizza testi in 50+ lingue."
print(normalize_text(testo))
# Output: "lnlp e fantastico analizza testi in 50 lingue"
Attenzione agli Accenti in Italiano
In molte pipeline NLP anglofone, la rimozione degli accenti e un passo standard. In italiano, pero, gli accenti cambiano il significato delle parole: "pero" (congiunzione) vs "pero" (albero), "e" (congiunzione) vs "e" (verbo essere). Non rimuovere mai gli accenti quando lavori con testo italiano.
1.2 Stopwords: Parole Senza Contenuto Informativo
Le stopwords sono parole molto frequenti che portano poco significato semantico: articoli, preposizioni, congiunzioni. Rimuoverle riduce la dimensionalità dei dati e aiuta i modelli a concentrarsi sulle parole significative.
Stopwords Italiane vs Inglesi
| Lingua | Esempi di Stopwords | Conteggio Tipico |
|---|---|---|
| Inglese | the, is, at, which, on, a, an, and, or | ~180 parole |
| Italiano | il, lo, la, di, a, da, in, con, su, per, che, e, non, un | ~300 parole |
L'italiano ha più stopwords dell'inglese a causa della maggiore ricchezza di articoli (il, lo, la, i, gli, le), preposizioni articolate (del, dello, della, nei, negli, nelle) e forme verbali ausiliari.
# Approccio 1: NLTK
from nltk.corpus import stopwords
import nltk
nltk.download('stopwords')
stop_it = set(stopwords.words('italian'))
print(f"Stopwords italiane NLTK: {len(stop_it)}")
# Output: Stopwords italiane NLTK: 279
testo = "il gatto mangia il pesce sul tavolo della cucina"
tokens = testo.split()
filtrato = [t for t in tokens if t not in stop_it]
print(filtrato)
# Output: ['gatto', 'mangia', 'pesce', 'tavolo', 'cucina']
# Approccio 2: spaCy (più completo)
import spacy
nlp = spacy.load("it_core_news_lg")
doc = nlp("il gatto mangia il pesce sul tavolo della cucina")
filtrato_spacy = [token.text for token in doc if not token.is_stop]
print(filtrato_spacy)
# Output: ['gatto', 'mangia', 'pesce', 'tavolo', 'cucina']
1.3 Stemming vs Lemmatization
Entrambe le tecniche riducono le parole alla loro forma base, ma lo fanno in modi molto diversi.
Stemming vs Lemmatization - Confronto
| Aspetto | Stemming | Lemmatization |
|---|---|---|
| Metodo | Taglia suffissi con regole euristiche | Usa un dizionario e analisi morfologica |
| Risultato | Stem (non sempre una parola reale) | Lemma (parola reale dal dizionario) |
| Esempio IT | "mangiando" -> "mangi" | "mangiando" -> "mangiare" |
| Velocita | Molto veloce | Più lenta (richiede dizionario) |
| Precisione | Bassa (over-stemming comune) | Alta (forme corrette) |
| Per l'italiano | Snowball Stemmer (Porter italiano) | spaCy it_core_news_lg |
from nltk.stem.snowball import SnowballStemmer
import spacy
# Stemming con Snowball
stemmer = SnowballStemmer("italian")
parole = ["mangiando", "mangiare", "mangiato", "correre", "correndo", "bellissimo"]
stems = [stemmer.stem(p) for p in parole]
print(dict(zip(parole, stems)))
# {'mangiando': 'mang', 'mangiare': 'mang', 'mangiato': 'mang',
# 'correre': 'corr', 'correndo': 'corr', 'bellissimo': 'bellissim'}
# Lemmatization con spaCy
nlp = spacy.load("it_core_news_lg")
doc = nlp("Le ragazze stavano mangiando le mele più belle")
lemmi = [(token.text, token.lemma_, token.pos_) for token in doc]
for testo, lemma, pos in lemmi:
print(f" {testo:15s} -> {lemma:15s} ({pos})")
# le -> il (DET)
# ragazze -> ragazza (NOUN)
# stavano -> stare (AUX)
# mangiando -> mangiare (VERB)
# le -> il (DET)
# mele -> mela (NOUN)
# più -> più (ADV)
# belle -> bello (ADJ)
Per l'italiano, la lemmatization con spaCy e quasi sempre preferibile
allo stemming. Il modello it_core_news_lg contiene 500.000 vettori di parole
e supporta tokenizzazione, POS tagging, parsing delle dipendenze, NER e lemmatizzazione.
2. Tokenizzazione: Come le Macchine Leggono il Testo
La tokenizzazione e il processo di suddivisione del testo in unita discrete chiamate token. E il primo e più critico passo di qualsiasi pipeline NLP: la qualità della tokenizzazione influenza direttamente le prestazioni di ogni modello successivo.
Esistono tre approcci fondamentali, ognuno con vantaggi e compromessi diversi.
2.1 Word-Level Tokenization
L'approccio più intuitivo: ogni parola diventa un token.
# Approccio naive: split per spazio
testo = "L'intelligenza artificiale cambia il mondo"
tokens_naive = testo.split()
print(tokens_naive)
# ["L'intelligenza", 'artificiale', 'cambia', 'il', 'mondo']
# Problema: "L'intelligenza" e un singolo token!
# Approccio migliore: spaCy
import spacy
nlp = spacy.load("it_core_news_lg")
doc = nlp(testo)
tokens_spacy = [token.text for token in doc]
print(tokens_spacy)
# ["L'", 'intelligenza', 'artificiale', 'cambia', 'il', 'mondo']
# spaCy gestisce correttamente le elisioni italiane
Limiti della Tokenizzazione Word-Level
- Vocabolario enorme: Ogni parola unica richiede un'entry nel vocabolario. L'italiano ha centinaia di migliaia di forme flesse
- Parole fuori vocabolario (OOV): Parole mai viste durante il training diventano <UNK> (sconosciute)
- Nessuna condivisione morfologica: "mangiare", "mangiando", "mangiato" sono tre token completamente separati, senza relazione
2.2 Character-Level Tokenization
All'estremo opposto, ogni carattere diventa un token. Il vocabolario e minuscolo (26 lettere + cifre + punteggiatura), ma le sequenze diventano lunghissime.
Testo: "ciao mondo"
Word-level: ["ciao", "mondo"] -> 2 token
Char-level: ["c","i","a","o"," ","m","o","n","d","o"] -> 10 token
Testo di 1000 parole:
Word-level: ~1.000 token
Char-level: ~5.000 token (5x più lungo!)
La tokenizzazione a caratteri risolve il problema delle parole sconosciute (qualsiasi parola può essere rappresentata), ma le sequenze molto lunghe rendono difficile per i modelli catturare relazioni a lungo raggio nel testo.
2.3 Subword Tokenization: Il Compromesso Ottimale
La tokenizzazione subword e il metodo usato da tutti i modelli moderni (BERT, GPT, LLaMA, T5). L'idea e brillante: le parole comuni restano intere, mentre le parole rare vengono spezzate in sotto-unita (subword) che il modello ha già visto.
Algoritmi di Subword Tokenization
| Algoritmo | Usato Da | Strategia | Direzione |
|---|---|---|---|
| BPE (Byte Pair Encoding) | GPT-2, GPT-3, GPT-4, LLaMA, RoBERTa | Merge iterativo delle coppie più frequenti | Bottom-up |
| WordPiece | BERT, DistilBERT, ELECTRA | Merge che massimizza la verosimiglianza | Bottom-up |
| SentencePiece | T5, ALBERT, XLNet, mBART | Tratta il testo come stream grezzo di caratteri | Indipendente da lingua |
| Unigram | SentencePiece (opzionale), ALBERT | Parte da vocabolario grande, rimuove token meno utili | Top-down |
Come funziona BPE (Byte Pair Encoding)
BPE parte dai singoli caratteri e iterativamente fonde le coppie più frequenti fino a raggiungere la dimensione di vocabolario desiderata.
Corpus: "basso basso bassa basso"
Passo 0 - Vocabolario iniziale (caratteri):
b, a, s, o
Passo 1 - Coppia più frequente: (s, s) -> "ss"
b a ss o b a ss o b a ss a b a ss o
Passo 2 - Coppia più frequente: (a, ss) -> "ass"
b ass o b ass o b ass a b ass o
Passo 3 - Coppia più frequente: (b, ass) -> "bass"
bass o bass o bass a bass o
Passo 4 - Coppia più frequente: (bass, o) -> "basso"
basso basso bass a basso
Vocabolario finale: [b, a, s, o, ss, ass, bass, basso]
WordPiece vs BPE
WordPiece usa un approccio simile a BPE, ma invece di scegliere la coppia più
frequente, sceglie quella che massimizza la verosimiglianza del corpus
di addestramento. In pratica, WordPiece preferisce merge che producono token più utili
per il modello linguistico, non semplicemente quelli più comuni. I token che non iniziano
una parola sono prefissati con ##.
SentencePiece: Indipendenza dalla Lingua
La differenza chiave di SentencePiece e che non richiede pre-tokenizzazione. BPE e WordPiece assumono che il testo sia già diviso in parole (tipicamente per spazio), il che funziona bene per inglese e italiano, ma fallisce per lingue come cinese, giapponese o thai che non usano spazi tra le parole. SentencePiece tratta il testo come uno stream grezzo di byte, rendendolo veramente indipendente dalla lingua.
3. Esempio Pratico: Tokenizzazione con HuggingFace
Vediamo concretamente come BERT e GPT-2 tokenizzano lo stesso testo italiano.
Useremo la libreria transformers di HuggingFace.
from transformers import AutoTokenizer
# Testo di esempio in italiano
testo = "L'intelligenza artificiale sta rivoluzionando il mondo"
# --- BERT (WordPiece) ---
bert_tok = AutoTokenizer.from_pretrained("dbmdz/bert-base-italian-cased")
bert_tokens = bert_tok.tokenize(testo)
bert_ids = bert_tok.encode(testo)
print("BERT tokens:", bert_tokens)
print("BERT IDs: ", bert_ids)
# BERT tokens: ['L', "'", 'intelligenza', 'artificiale', 'sta',
# 'rivoluzionando', 'il', 'mondo']
# BERT IDs: [102, 55, 7, 5765, 6892, 379, 28648, 42, 1601, 103]
# --- GPT-2 (BPE) ---
gpt2_tok = AutoTokenizer.from_pretrained("gpt2")
gpt2_tokens = gpt2_tok.tokenize(testo)
gpt2_ids = gpt2_tok.encode(testo)
print("\nGPT-2 tokens:", gpt2_tokens)
print("GPT-2 IDs: ", gpt2_ids)
# GPT-2 tokens: ['L', "'", 'int', 'ell', 'ig', 'enza', ' art',
# 'ific', 'iale', ' sta', ' riv', 'oluz', 'ion',
# 'ando', ' il', ' mondo']
# --- Confronto ---
print(f"\nBERT: {len(bert_tokens)} token")
print(f"GPT-2: {len(gpt2_tokens)} token")
Osservazioni Chiave
- BERT italiano (
dbmdz/bert-base-italian-cased) riconosce "intelligenza" e "rivoluzionando" come token interi, perchè il suo vocabolario e stato addestrato su testo italiano - GPT-2 spezza le parole italiane in molti più subword, perchè il suo vocabolario e stato addestrato principalmente su testo inglese
- Un tokenizzatore addestrato sulla lingua target produce meno token, il che significa più contesto nella finestra di attenzione e costi minori per token
- BERT aggiunge token speciali:
[CLS]all'inizio e[SEP]alla fine. GPT-2 non lo fa
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("dbmdz/bert-base-italian-cased")
# Dimensione vocabolario
print(f"Vocabolario BERT italiano: {tok.vocab_size} token")
# Output: Vocabolario BERT italiano: 31102 token
# Token speciali
print(f"[CLS] = {tok.cls_token} (ID: {tok.cls_token_id})")
print(f"[SEP] = {tok.sep_token} (ID: {tok.sep_token_id})")
print(f"[PAD] = {tok.pad_token} (ID: {tok.pad_token_id})")
print(f"[UNK] = {tok.unk_token} (ID: {tok.unk_token_id})")
print(f"[MASK] = {tok.mask_token} (ID: {tok.mask_token_id})")
# Decodifica: da token IDs -> testo
ids = tok.encode("NLP e fantastico")
print(f"\nEncode: {ids}")
print(f"Decode: {tok.decode(ids)}")
# Decode: [CLS] NLP e fantastico [SEP]
4. Bag of Words e TF-IDF: Le Rappresentazioni Classiche
Prima dei word embeddings, il testo veniva rappresentato come vettori sparsi basati sulla frequenza delle parole. Questi metodi sono ancora usati in molti contesti e comprendere i loro limiti aiuta a capire perchè gli embeddings sono stati una rivoluzione.
4.1 Bag of Words (BoW)
Il modello Bag of Words rappresenta un documento come un vettore dove ogni posizione corrisponde a una parola del vocabolario e il valore e il numero di occorrenze di quella parola nel documento.
from sklearn.feature_extraction.text import CountVectorizer
documenti = [
"il gatto mangia il pesce",
"il cane mangia la carne",
"il gatto insegue il cane"
]
vectorizer = CountVectorizer()
bow_matrix = vectorizer.fit_transform(documenti)
print("Vocabolario:", vectorizer.get_feature_names_out())
# ['cane', 'carne', 'gatto', 'il', 'insegue', 'la', 'mangia', 'pesce']
print("\nMatrice BoW:")
print(bow_matrix.toarray())
# [[0, 0, 1, 2, 0, 0, 1, 1], # doc 1
# [1, 1, 0, 1, 0, 1, 1, 0], # doc 2
# [1, 0, 1, 2, 1, 0, 0, 0]] # doc 3
4.2 TF-IDF (Term Frequency - Inverse Document Frequency)
TF-IDF migliora BoW pesando le parole per la loro importanza relativa. Parole frequenti in un documento ma rare nel corpus complessivo ricevono un peso maggiore. Parole comuni ovunque (come "il", "la") ricevono peso basso.
TF-IDF(t, d) = TF(t, d) x IDF(t)
dove:
TF(t, d) = frequenza del termine t nel documento d
IDF(t) = log(N / df(t))
N = numero totale di documenti
df(t) = numero di documenti che contengono il termine t
Esempio:
Parola "gatto" in documento 1:
TF = 1/5 = 0.2 (1 occorrenza su 5 parole)
IDF = log(3/2) = 0.405 (appare in 2 documenti su 3)
TF-IDF = 0.2 x 0.405 = 0.081
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
documenti = [
"il gatto mangia il pesce",
"il cane mangia la carne",
"il gatto insegue il cane"
]
tfidf = TfidfVectorizer()
tfidf_matrix = tfidf.fit_transform(documenti)
print("Feature:", tfidf.get_feature_names_out())
print("\nMatrice TF-IDF (arrotondata):")
print(np.round(tfidf_matrix.toarray(), 3))
# "pesce" e "carne" hanno pesi più alti perchè appaiono
# in un solo documento (più discriminanti)
Limiti di BoW e TF-IDF
- Nessuna semantica: "cane" e "canino" sono completamente diversi; "banca" (fiume) e "banca" (istituto) sono identici
- Nessun ordine: "il gatto mangia il topo" e "il topo mangia il gatto" hanno la stessa rappresentazione
- Alta dimensionalità: Un vocabolario di 100.000 parole produce vettori a 100.000 dimensioni, quasi tutti zeri (vettori sparsi)
- Nessuna generalizzazione: Non catturano relazioni tra parole ("re" e "regina" non hanno alcuna vicinanza)
5. Word Embeddings: Il Significato Come Geometria
I word embeddings hanno rivoluzionato l'NLP trasformando le parole in vettori densi a bassa dimensionalità (tipicamente 100-300 dimensioni) che catturano le relazioni semantiche tra le parole. Due parole con significato simile avranno vettori vicini nello spazio vettoriale.
5.1 Word2Vec: L'Invenzione che ha Cambiato Tutto
Introdotto da Tomas Mikolov e colleghi (Google, 2013), Word2Vec impara i vettori delle parole a partire dal contesto in cui appaiono. L'intuizione fondamentale e la distributional hypothesis: "una parola e caratterizzata dalla compagnia che tiene" (J.R. Firth, 1957).
Due Architetture Word2Vec
| Architettura | Input | Output | Intuizione |
|---|---|---|---|
| CBOW (Continuous Bag of Words) | Contesto (parole circostanti) | Parola target | Dato il contesto "il ___ mangia", predici "gatto" |
| Skip-gram | Parola target | Contesto (parole circostanti) | Data la parola "gatto", predici "il", "mangia", ecc. |
In pratica, Skip-gram funziona meglio con piccoli dataset e cattura meglio le parole rare. CBOW e più veloce e funziona bene con parole frequenti.
Frase: "il gatto nero mangia il pesce fresco"
^
parola target
Con window_size = 2, Skip-gram impara:
gatto -> il (contesto a sinistra, distanza 1)
gatto -> nero (contesto a destra, distanza 1)
gatto -> mangia (contesto a destra, distanza 2)
Dopo milioni di frasi, parole che appaiono in contesti
simili avranno vettori simili:
gatto ~ felino ~ micio (contesti simili: "il ___ mangia")
cane ~ canino ~ cucciolo (contesti simili: "il ___ corre")
5.2 L'Aritmetica delle Parole
La proprietà più sorprendente dei word embeddings e che le relazioni semantiche diventano operazioni algebriche sui vettori. La famosa analogia:
re - uomo + donna = regina
Non e una coincidenza: il vettore che porta da "uomo" a "donna" e lo stesso che porta da "re" a "regina". Questo funziona per molte relazioni: paese-capitale, verbo-tempo passato, aggettivo-superlativo.
import gensim.downloader as api
# Carica word embeddings pre-addestrati
model = api.load("word2vec-google-news-300")
# Analogia: re - uomo + donna = ?
result = model.most_similar(
positive=["king", "woman"],
negative=["man"],
topn=3
)
print("king - man + woman =")
for word, score in result:
print(f" {word}: {score:.4f}")
# king - man + woman =
# queen: 0.7118
# monarch: 0.6189
# princess: 0.5902
# Similarità tra parole
print(f"\ncat ~ dog: {model.similarity('cat', 'dog'):.4f}")
print(f"cat ~ car: {model.similarity('cat', 'car'):.4f}")
# cat ~ dog: 0.7609
# cat ~ car: 0.2004
5.3 GloVe: Global Vectors for Word Representation
GloVe (Stanford, 2014) prende un approccio diverso: invece di predire il contesto parola per parola come Word2Vec, GloVe costruisce prima una matrice di co-occorrenza globale di tutto il corpus, poi fattorizza questa matrice per ottenere i vettori. Combina i vantaggi dei metodi basati sulla statistica globale con quelli dell'apprendimento locale di Word2Vec.
Word2Vec vs GloVe
| Aspetto | Word2Vec | GloVe |
|---|---|---|
| Metodo | Predittivo (rete neurale) | Count-based (fattorizzazione matrice) |
| Contesto | Finestra locale | Statistiche globali del corpus |
| Training | Online (scorre il testo) | Batch (matrice completa) |
| Dimensioni comuni | 100, 200, 300 | 50, 100, 200, 300 |
| Prestazioni | Eccellente per analogie | Eccellente per similarità |
6. Embeddings Contestuali: La Stessa Parola, Significati Diversi
Word2Vec e GloVe hanno un limite fondamentale: assegnano un unico vettore a ogni parola, indipendentemente dal contesto. Ma il linguaggio e pieno di ambiguità: la parola "banco" ha un significato completamente diverso in "il banco di scuola" e "il banco di Napoli".
Gli embeddings contestuali risolvono questo problema: il vettore di una parola dipende dall'intera frase in cui appare. Questo e l'approccio usato da BERT, GPT e tutti i modelli basati su Transformer.
Embeddings Statici vs Contestuali
| Aspetto | Statici (Word2Vec, GloVe) | Contestuali (BERT, GPT) |
|---|---|---|
| Vettore per parola | Uno fisso, sempre uguale | Diverso in base al contesto |
| Polisemia | Non gestita ("banco" ha un solo vettore) | Gestita ("banco" ha vettori diversi per ogni significato) |
| Modello | Lookup table | Rete neurale profonda (Transformer) |
| Dimensione modello | Pochi MB | Centinaia di MB o GB |
| Velocita | Istantanea | Richiede forward pass |
from transformers import AutoTokenizer, AutoModel
import torch
import torch.nn.functional as F
# Carica BERT italiano
tokenizer = AutoTokenizer.from_pretrained("dbmdz/bert-base-italian-cased")
model = AutoModel.from_pretrained("dbmdz/bert-base-italian-cased")
def get_word_embedding(sentence: str, word: str):
"""Ottieni l'embedding contestuale di una parola nella frase."""
inputs = tokenizer(sentence, return_tensors="pt")
tokens = tokenizer.tokenize(sentence)
with torch.no_grad():
outputs = model(**inputs)
# outputs.last_hidden_state: [batch, seq_len, hidden_dim]
embeddings = outputs.last_hidden_state[0] # [seq_len, 768]
# Trova l'indice del token target
word_idx = tokens.index(word) + 1 # +1 per [CLS]
return embeddings[word_idx]
# "banco" in contesti diversi
emb_scuola = get_word_embedding("Il banco di scuola e rotto", "banco")
emb_banca = get_word_embedding("Il banco di Napoli e storico", "banco")
emb_pesce = get_word_embedding("Il banco del pesce e fresco", "banco")
# Calcola similarità coseno
sim_12 = F.cosine_similarity(emb_scuola.unsqueeze(0), emb_banca.unsqueeze(0))
sim_13 = F.cosine_similarity(emb_scuola.unsqueeze(0), emb_pesce.unsqueeze(0))
sim_23 = F.cosine_similarity(emb_banca.unsqueeze(0), emb_pesce.unsqueeze(0))
print(f"banco(scuola) ~ banco(banca): {sim_12.item():.4f}")
print(f"banco(scuola) ~ banco(pesce): {sim_13.item():.4f}")
print(f"banco(banca) ~ banco(pesce): {sim_23.item():.4f}")
# I vettori saranno DIVERSI perchè BERT capisce il contesto!
Questo e il salto concettuale fondamentale: con BERT, la parola "banco" non ha più un significato fisso. Il suo vettore cambia in base a ciò che la circonda, proprio come accade nella comprensione umana del linguaggio.
7. Sentence Embeddings: Un Vettore per un'Intera Frase
Spesso non ci serve l'embedding di una singola parola, ma di un'intera frase o paragrafo. I sentence embeddings comprimono il significato di un testo di qualsiasi lunghezza in un singolo vettore a dimensione fissa.
Il modello di riferimento e Sentence-BERT (SBERT), che modifica
l'architettura BERT per produrre sentence embeddings ottimizzati per il confronto
di similarità. Per l'italiano, il modello paraphrase-multilingual-MiniLM-L12-v2
supporta 50+ lingue con vettori a 384 dimensioni.
Applicazioni dei Sentence Embeddings
| Applicazione | Descrizione | Come Funziona |
|---|---|---|
| Semantic Search | Ricerca per significato, non per keyword | Embeddi la query, cerca i documenti più vicini |
| Clustering | Raggruppa testi simili automaticamente | K-means o HDBSCAN sugli embeddings |
| Duplicate Detection | Trova duplicati o quasi-duplicati | Soglia di cosine similarity > 0.9 |
| Classificazione Zero-Shot | Classifica senza dati di training | Confronta embedding testo con embedding etichette |
from sentence_transformers import SentenceTransformer, util
# Modello multilingue (supporta italiano)
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
# Frasi in italiano
frasi = [
"Il gatto dorme sul divano",
"Il felino riposa sul sofa",
"La borsa e salita oggi",
"I mercati finanziari sono in crescita",
"Ho comprato un nuovo computer portatile"
]
# Genera embeddings (1 vettore per frase, 384 dimensioni)
embeddings = model.encode(frasi, convert_to_tensor=True)
print(f"Shape: {embeddings.shape}") # [5, 384]
# Calcola matrice di similarità
cosine_scores = util.cos_sim(embeddings, embeddings)
print("\nMatrice di Similarità:")
for i in range(len(frasi)):
for j in range(i + 1, len(frasi)):
print(f" {frasi[i][:40]:40s} <-> {frasi[j][:40]:40s}")
print(f" Similarità: {cosine_scores[i][j]:.4f}")
# Risultati attesi:
# "gatto dorme" <-> "felino riposa" : ~0.85 (molto simili)
# "borsa salita" <-> "mercati crescita": ~0.70 (correlati)
# "gatto dorme" <-> "borsa salita" : ~0.10 (non correlati)
8. La Pipeline NLP Moderna
Abbiamo visto i singoli componenti. Ora mettiamoli insieme per capire come funziona una pipeline NLP moderna end-to-end, quella usata da BERT, GPT e tutti i modelli basati su Transformer.
Testo Grezzo
|
v
[1. TOKENIZZAZIONE]
Input: "L'intelligenza artificiale e fantastica"
Output: ["L'", "intelligenza", "artificiale", "e", "fantastica"]
|
v
[2. ENCODING (Token -> ID)]
Input: ["L'", "intelligenza", "artificiale", "e", "fantastica"]
Output: [102, 55, 5765, 6892, 15, 23456, 103]
^[CLS] ^[SEP]
|
v
[3. EMBEDDING LAYER]
Input: [102, 55, 5765, 6892, 15, 23456, 103]
Output: Matrice [7 x 768] - un vettore 768-dim per ogni token
|
v
[4. TRANSFORMER ENCODER/DECODER]
Self-Attention: ogni token "guarda" tutti gli altri
Input: Matrice [7 x 768]
Output: Matrice [7 x 768] (vettori contestualizzati)
|
v
[5. TASK HEAD]
Classificazione: [CLS] embedding -> softmax -> classe
NER: ogni token -> etichetta entità
Generazione: ultimo token -> prossimo token
QA: posizione inizio/fine risposta
Il Ruolo del Token [CLS]
In BERT, il token speciale [CLS] viene inserito all'inizio di ogni input.
Dopo il passaggio attraverso tutti i layer del Transformer, il suo embedding rappresenta
l'intera sequenza. E usato come input per i task di classificazione
(sentiment analysis, rilevamento spam, ecc.).
9. NLP per l'Italiano: Specificità e Strumenti
La lingua italiana presenta sfide uniche per l'NLP che la distinguono dall'inglese e da altre lingue. Conoscere queste secificità e essenziale per costruire sistemi NLP efficaci per l'italiano.
9.1 Sfide Linguistiche dell'Italiano
Particolarita dell'Italiano per l'NLP
| Sfida | Descrizione | Esempio |
|---|---|---|
| Ricca morfologia | Ogni verbo ha decine di forme coniugate | "mangiare" ha 50+ forme (mangio, mangi, mangia, mangiamo...) |
| Elisioni e apostrofi | Articoli e preposizioni si fondono | "l'uomo", "dell'arte", "un'amica", "quest'anno" |
| Preposizioni articolate | Preposizione + articolo in una parola | "del" (di+il), "nello" (in+lo), "sulla" (su+la) |
| Accenti significativi | Cambiano il significato | "e" (and) vs "e" (is), "da" (from) vs "da" (gives) |
| Pronomi clitici | Si attaccano al verbo | "dammelo" (dai+me+lo), "portarglielo" |
| Ordine libero | SVO non e obbligatorio | "La torta la mangia Marco" = "Marco mangia la torta" |
9.2 Modelli Pre-addestrati per l'Italiano
Principali Modelli Italiani
| Modello | Base | Task | Repository |
|---|---|---|---|
| dbmdz/bert-base-italian-cased | BERT | General-purpose NLP italiano | HuggingFace |
| AlBERTo | BERT | Social media italiano (Twitter) | HuggingFace |
| feel-it-italian-sentiment | UmBERTo | Sentiment analysis italiano | MilaNLProc |
| feel-it-italian-emotion | UmBERTo | Emotion detection (gioia, rabbia, paura, tristezza) | MilaNLProc |
| Italian-Legal-BERT | BERT | Testi legali italiani | dlicari |
| DeepMount00/Italian_NER_XXL | BERT | Named Entity Recognition italiano | HuggingFace |
| it_core_news_lg | spaCy CNN | Tokenizzazione, POS, NER, lemma, parsing | spaCy |
9.3 Preprocessing Specifico per l'Italiano
import spacy
import re
class ItalianPreprocessor:
"""Pipeline di preprocessing specifica per l'italiano."""
def __init__(self):
self.nlp = spacy.load("it_core_news_lg")
# Stopwords aggiuntive regionali/informali
self.custom_stops = {
"cioe", "quindi", "comunque", "praticamente",
"allora", "insomma", "magari", "ecco", "tipo",
"boh", "mah", "vabbe", "ok", "okay"
}
def preprocess(self, text: str, remove_stops: bool = True,
lemmatize: bool = True) -> list[str]:
"""Preprocessing completo per testo italiano."""
# 1. Normalizzazione base
text = text.lower()
text = re.sub(r'http\S+|www\.\S+', '', text) # rimuovi URL
text = re.sub(r'[^\w\s\']', '', text) # mantieni apostrofi
text = re.sub(r'\d+', '', text) # rimuovi numeri
text = re.sub(r'\s+', ' ', text).strip()
# 2. Analisi con spaCy
doc = self.nlp(text)
# 3. Filtraggio e lemmatizzazione
tokens = []
for token in doc:
# Salta punteggiatura e spazi
if token.is_punct or token.is_space:
continue
# Salta stopwords se richiesto
if remove_stops and (token.is_stop or
token.text in self.custom_stops):
continue
# Lemmatizza o usa la forma originale
word = token.lemma_ if lemmatize else token.text
if len(word) > 1: # salta caratteri singoli
tokens.append(word)
return tokens
# Esempio d'uso
prep = ItalianPreprocessor()
testo = """L'intelligenza artificiale sta rivoluzionando
il modo in cui le aziende italiane gestiscono i loro
processi, cioe praticamente tutto sta cambiando."""
risultato = prep.preprocess(testo)
print("Token processati:", risultato)
# ['intelligenza', 'artificiale', 'rivoluzionare', 'modo',
# 'azienda', 'italiano', 'gestire', 'processo', 'cambiare']
10. Esempio End-to-End: Semantic Search in Italiano
Mettiamo insieme tutto ciò che abbiamo imparato in un esempio completo: una ricerca semantica su un corpus di testi italiani. Dato un insieme di documenti e una query dell'utente, troveremo i documenti più rilevanti usando sentence embeddings.
from sentence_transformers import SentenceTransformer, util
import torch
class SemanticSearchIT:
"""Motore di ricerca semantica per testi italiani."""
def __init__(self, model_name: str =
"paraphrase-multilingual-MiniLM-L12-v2"):
self.model = SentenceTransformer(model_name)
self.documents: list[str] = []
self.embeddings = None
def index_documents(self, documents: list[str]) -> None:
"""Indicizza i documenti calcolando gli embeddings."""
self.documents = documents
self.embeddings = self.model.encode(
documents,
convert_to_tensor=True,
show_progress_bar=True
)
print(f"Indicizzati {len(documents)} documenti")
print(f"Shape embeddings: {self.embeddings.shape}")
def search(self, query: str, top_k: int = 3) -> list[dict]:
"""Cerca i documenti più rilevanti per la query."""
query_embedding = self.model.encode(
query, convert_to_tensor=True
)
scores = util.cos_sim(query_embedding, self.embeddings)[0]
top_results = torch.topk(scores, k=min(top_k, len(self.documents)))
results = []
for score, idx in zip(top_results.values, top_results.indices):
results.append({
"documento": self.documents[idx],
"score": round(score.item(), 4),
"indice": idx.item()
})
return results
# --- Esempio d'uso ---
corpus = [
"Python e un linguaggio di programmazione versatile e facile da imparare",
"Il machine learning permette ai computer di imparare dai dati",
"Angular e un framework per costruire applicazioni web moderne",
"La pasta alla carbonara e un piatto tipico della cucina romana",
"I database relazionali usano SQL per interrogare i dati",
"Il deep learning utilizza reti neurali con molti strati nascosti",
"Roma e la capitale d'Italia e ha una storia millenaria",
"Le API REST permettono la comunicazione tra servizi web",
"Il Natural Language Processing analizza e comprende il testo",
"La pizza napoletana e patrimonio UNESCO dal 2017"
]
# Crea il motore di ricerca e indicizza
search_engine = SemanticSearchIT()
search_engine.index_documents(corpus)
# Esegui alcune ricerche
queries = [
"come analizzare il linguaggio naturale",
"framework per sviluppo frontend",
"cucina tradizionale italiana"
]
for query in queries:
print(f"\nQuery: '{query}'")
print("-" * 60)
results = search_engine.search(query, top_k=3)
for i, r in enumerate(results, 1):
print(f" {i}. [{r['score']:.4f}] {r['documento']}")
Risultati Attesi
La ricerca semantica comprende il significato, non solo le parole. Ad esempio:
- "come analizzare il linguaggio naturale" trovera il documento sull'NLP anche se non contiene esattamente quelle parole
- "framework per sviluppo frontend" trovera Angular, anche se "frontend" non appare nel documento (ma "applicazioni web moderne" e semanticamente correlato)
- "cucina tradizionale italiana" trovera sia la carbonara che la pizza, perchè il modello capisce la relazione semantica
Roadmap: Da Qui ai LLM
In questo articolo abbiamo costruito le fondamenta dell'NLP moderno, partendo dal preprocessing del testo fino agli embeddings contestuali e alla pipeline completa. Riassumiamo il percorso che abbiamo compiuto:
Riepilogo dei Concetti
| Concetto | Cosa Fa | Evoluzione |
|---|---|---|
| Preprocessing | Pulisce e normalizza il testo grezzo | Regole manuali -> spaCy pipeline |
| Tokenizzazione | Divide il testo in unita discrete | Word -> Char -> Subword (BPE/WordPiece) |
| BoW / TF-IDF | Rappresenta il testo come vettori sparsi | Semplice ma senza semantica |
| Word Embeddings | Vettori densi che catturano il significato | Word2Vec -> GloVe -> FastText |
| Embeddings Contestuali | Vettori che dipendono dal contesto | ELMo -> BERT -> GPT |
| Sentence Embeddings | Un vettore per un'intera frase | Media pooling -> Sentence-BERT |
Nel prossimo articolo faremo il salto verso l'architettura che ha rivoluzionato tutto: il Transformer. Vedremo in dettaglio il meccanismo di Self-Attention, capiremo perchè BERT e stato un punto di svolta, e impareremo a usarlo per task reali come la classificazione del testo e il question answering.
Risorse per Approfondire
- spaCy Italian Models: Documentazione ufficiale per i modelli italiani di spaCy (spacy.io/models/it)
- HuggingFace Models: Repository di modelli italiani pre-addestrati (huggingface.co/models?language=it)
- Sentence-BERT: Documentazione di sentence-transformers (sbert.net)
- FEEL-IT: Sentiment analysis e emotion classification per l'italiano (MilaNLProc)
- Paper Word2Vec: "Efficient Estimation of Word Representations in Vector Space" (Mikolov et al., 2013)
- Paper GloVe: "Global Vectors for Word Representation" (Pennington et al., 2014)
- Paper BERT: "BERT: Pre-training of Deep Bidirectional Transformers" (Devlin et al., 2019)







