Un'esplorazione del token embedding con un transformer¶
Luca Mari, aggiornamento, maggio 2024
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Non commerciale - Condividi allo stesso modo 4.0 Internazionale.
[i file di questa attività: embed.ipynb, embedutils.py, embedexpl.py, embedtempl.html]
Obiettivi: comprendere la logica del token embedding (chiamato a volte "word embedding"), il processo con cui una rete neurale artificiale converte espressioni linguistiche semplici (parole appunto, e più correttamente token, da un vocabolario) nei vettori numerici su cui i transformer poi operano.
Precompetenze: basi di Python (il codice Python è ovunque semplice, perché richiama funzioni che nascondono molti dettagli).
Per eseguire questo notebook, supponiamo con VSCode, occorre:
- installare un interprete Python
- scaricare da https://code.visualstudio.com/download e installare VSCode
- eseguire VSCode e attivare le estensioni per Python e Jupyter
- ancora in VSCode:
- creare una cartella di lavoro e renderla la cartella corrente:
- copiare nella cartella questo notebook e il file
embedutils.py
e aprire il notebook- creare un ambiente virtuale locale Python (Select Kernel | Python Environments | Create Python Environment | Venv, e scegliere un interprete Python):
- installare i moduli Python richiesti, eseguendo dal terminale:
pip install torch transformers multimethod colorama python-docx
Lo spazio di word embedding può essere esplorato anche interattivamente, mediante un'applicazione web. Per eseguire il visualizzatore interattivo web occorre:
- aver eseguito almeno una volta questo notebook, in modo da aver scaricato il transformer
BERT
- copiare nella stessa cartella con questo notebook e
embedutils.py
anche i fileembedexpl.py
eembedtempl.html
- installare altri moduli Python, ancora eseguendo dal terminale:
pip install flask networkx
- eseguire il visualizzatore
embedexpl.py
- una volta avviata l'applicazione, aprire un browser e digitare l'indirizzo:
http://127.0.0.1:5000
Per prima cosa, importiamo il modulo che contiene le funzioni per consentire un accesso "di alto livello" al modello pre-addestrato che opererà sia come tokenizzatore sia come sistema di embedding, usando in questo caso una versione pre-addestrata e fine tuned, su testi in italiano, di BERT
, che è un transformer accessibile liberamente (https://it.wikipedia.org/wiki/BERT) ed eseguibile anche localmente (alla prima esecuzione sarà dunque necessario attendere che il modello sia scaricato dal sito di Hugging Face: è un file di circa 400 MB che viene copiato nella cartella HFdata della propria cartella personale) (non discutiamo qui di come questo modello sia stato addestrato a fare embedding).
Dopo aver caricato il modello, verifichiamo che il processo sia andato a buon fine visualizzando le due informazioni principali:
- il numero di token riconosciuti nel vocabolario del modello (
model.vocab_size
); - la dimensione del vettore in cui ogni token viene embedded (
model.embedding_dim
).
from embedutils import Model
from pprint import pprint
model = Model('dbmdz/bert-base-italian-xxl-cased', True)
print(f"Il tokenizzatore ha un vocabolario di {model.vocab_size} token che riconosce.")
print(f"Ogni token viene mappato ('embedded') in un vettore di {model.embedding_dim} numeri.")
print(f"La matrice degli embeddings ha perciò dimensione {model.vocab_embeddings.shape}")
Il tokenizzatore ha un vocabolario di 31102 token che riconosce. Ogni token viene mappato ('embedded') in un vettore di 768 numeri. La matrice degli embeddings ha perciò dimensione (32102, 768)
Il tokenizzatore mantiene un vocabolario dei token che riconosce, in una tabella in cui a ogni token è associato un identificatore univoco (id
).
token = "bellezza"
token_id = model.token_to_id(token)
print(f"Dato un token, come '{token}', il tokenizzatore è in grado di trovarne l'identificatore: {token_id}")
print(f"(ai token non presenti nel vocabolario è associato l'identificatore {model.tokenizer.convert_tokens_to_ids(model.tokenizer.unk_token)}).")
Dato un token, come 'bellezza', il tokenizzatore è in grado di trovarne l'identificatore: 6108 (ai token non presenti nel vocabolario è associato l'identificatore 101).
Il modello è stato addestrato a mappare (to embed, appunto) ogni token, con il suo identificatore, in un vettore di numeri (c'è da considerare che i transformer, come BERT
, operano sulla base di un embedding dinamico, in cui il vettore di numeri associato a ogni token dipende anche dal contesto ('embedding posizionale'): qui noi lavoriamo solo con la componente statica del maaping).
embedding = model.token_to_embedding(token)
print(f"Il token '{token}' è mappato in un vettore di {len(embedding)} elementi e i cui primi 5 elementi sono:\n{embedding[:5]}")
Il token 'bellezza' è mappato in un vettore di 768 elementi e i cui primi 5 elementi sono: [ 0.03051873 0.01173639 -0.04997671 0.0277972 0.02349026]
L'embedding è dunque una funzione dall'insieme dei token riconosciuti, cioè il vocabolario, allo spazio metrico dei vettori a n dimensioni. Il modello che realizza tale funzione è addestrato in modo tale da cercare di indurre una struttura metrica sul vocabolario, sulla base del principio che token di significato simile dovrebbero essere associati a vettori vicini. Dato un token, è così possibile elencare i token che gli sono più simili nel vocabolario, cioè quelli che associati a vettori più vicini al vettore del token dato.
pprint(model.most_similar(token, top_n=5))
[('bellezze', 0.45), ('dolcezza', 0.38), ('splendore', 0.38), ('estetica', 0.35), ('fascino', 0.34)]
L'embedding consente di operare in modo piuttosto sofisticato sul vocabolario.
Per esempio, dati due token $A$ e $B$ a ognuno dei quali è stato associato un vettore, $v(A)$ e $v(B)$, se la regola di associazione $v(.)$ è sufficientemente ricca da un punto di vista semantico allora il vettore differenza $v(A)-v(B)$ è associato alla relazione tra $A$ e $B$. In questo modo diventa possibile operare con relazioni di "proporzionalità semantica", del tipo: data la relazione tra $"re"$ e $"uomo"$, qual è il token $X$ che è nella stessa relazione con $"donna"$? Questa domanda è dunque codificata come $v("re")-v("uomo")=v(X)-v("donna")$, e perciò $v(X)=v("re")+v("donna")-v("uomo)$, in cui $"re"$ e $"donna"$ sono gli "esempi positivi" e $"uomo"$ è l'"esempio negativo".
positive_examples = ["re", "donna"] # sovrano donna
negative_examples = ["uomo"]
print(model.most_similar(positive_examples, negative_examples))
[('regina', 0.31)]
In accordo a questo principio, possiamo sperimentare le capacità di "proporzionalità semantica" del modello che stiamo usando con alcuni altri esempi analoghi.
positive_examples = ["Roma", "Francia"] # capitale della Francia
negative_examples = ["Italia"]
print(model.most_similar(positive_examples, negative_examples))
[('Parigi', 0.48)]
positive_examples = ["Italia", "Catalogna"] # stato a cui appartiene la Catalogna
negative_examples = ["Lombardia"]
print(model.most_similar(positive_examples, negative_examples))
[('Spagna', 0.39)]
positive_examples = ["Garibaldi", "Francia"] # eroe nazionale francese
negative_examples = ["Italia"]
print(model.most_similar(positive_examples, negative_examples))
[('Bonaparte', 0.44)]
positive_examples = ["estate", "freddo"] # stagione fredda
negative_examples = ["caldo"]
print(model.most_similar(positive_examples, negative_examples))
[('inverno', 0.5)]
positive_examples = ["chitarra", "pianista"] # strumento del pianista
negative_examples = ["chitarrista"]
print(model.most_similar(positive_examples, negative_examples))
[('pianoforte', 0.63)]
positive_examples = ["chitarra", "pianista"] # strumento del pianista
negative_examples = ["chitarrista"]
print(model.most_similar(positive_examples, negative_examples))
[('pianoforte', 0.63)]
positive_examples = ["nuoto", "palestra"] # sport praticato in palestra
negative_examples = ["piscina"]
print(model.most_similar(positive_examples, negative_examples))
[('ginnastica', 0.4)]
positive_examples = ["padre", "figlia"] # genitore femmina
negative_examples = ["figlio"]
print(model.most_similar(positive_examples, negative_examples))
[('madre', 0.56)]
positive_examples = ["bello", "cattivo"] # opposto di "cattivo"
negative_examples = ["brutto"]
print(model.most_similar(positive_examples, negative_examples))
[('buono', 0.37)]
Se i precedenti sono esempi ricchi semanticamente, proviamo a sperimentare anche con esempi solo grammaticali.
positive_examples = ["bianca", "nero"] # femminile di "nero"
negative_examples = ["bianco"]
print(model.most_similar(positive_examples, negative_examples))
[('nera', 0.56)]
positive_examples = ["treno", "automobili"] # singolare di "automobili"
negative_examples = ["treni"]
print(model.most_similar(positive_examples, negative_examples))
[('automobile', 0.54)]
positive_examples = ["andare", "guardato"] # infinito di "guardato"
negative_examples = ["andato"]
print(model.most_similar(positive_examples, negative_examples))
[('guardare', 0.57)]
positive_examples = ["pensando", "ascoltare"] # gerundio di "ascoltare"
negative_examples = ["pensare"]
print(model.most_similar(positive_examples, negative_examples))
[('ascoltando', 0.66)]
positive_examples = ["attore", "donna"] # femminile di "attore"
negative_examples = ["uomo"]
print(model.most_similar(positive_examples, negative_examples))
[('attrice', 0.63)]