Un'esplorazione del token embedding con un transformer¶

Luca Mari, gennaio 2025

Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Non commerciale - Condividi allo stesso modo 4.0 Internazionale.
No description has been provided for this image

Obiettivo: comprendere la logica della "tokenizzazione", il processo con cui un testo viene trasformato in una successione di elementi linguistici elementari ("token").
Precompetenze: basi di Python.

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 i file di questa attività: embed.ipynb, tokenizeutils.py]
    • aprire il notebook embed.ipynb
    • 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

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 in una cache locale) (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).
In [1]:
from tokenizeutils 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 associato ('embedded') a un vettore di {model.embedding_dim} numeri.")
Il tokenizzatore ha un vocabolario di 31102 token che riconosce.
Ogni token viene associato ('embedded') a un vettore di 768 numeri.

Il tokenizzatore mantiene un vocabolario dei token che riconosce, in una tabella in cui a ogni token è associato un identificatore univoco (id).

In [2]:
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)}).") # type: ignore
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 una successione di numeri (c'è da considerare che i transformer, come BERT, operano sulla base di un embedding dinamico, in cui la successione di numeri associata a ogni token dipende anche dal contesto ('embedding posizionale'): qui noi lavoriamo solo con la componente statica del mapping).

In [3]:
embedding = model.token_to_embedding(token)
print(f"Il token '{token}' è associato a un vettore di {len(embedding)} elementi e i cui primi 5 elementi sono:\n{embedding[:5]}")
Il token 'bellezza' è associato a 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.

In [4]:
n = 10
print(f"\nI {n} token più simili a '{token}' nel vocabolario:")
pprint(model.most_similar(token, top_n=n))
I 10 token più simili a 'bellezza' nel vocabolario:
[('bellezze', 0.45),
 ('dolcezza', 0.38),
 ('splendore', 0.38),
 ('estetica', 0.35),
 ('fascino', 0.34),
 ('Belle', 0.34),
 ('eleganza', 0.32),
 ('bellissima', 0.32),
 ('meraviglia', 0.31),
 ('bella', 0.31)]

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 $v(A)-v(B)$ è associato alla relazione tra $A$ e $B$, interpretata dunque come la loro differenza.
In questo modo diventa possibile operare con relazioni semantiche tra token. Per esempio, data la relazione tra $"re"$ e $"uomo"$, qual è il token $X$ che è nella stessa relazione di $"re"$ ma questa volta 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 i "casi positivi" e $"uomo"$ è il "caso negativo".

In [5]:
positive_examples = ["re", "donna"]             # sovrano donna
negative_examples = ["uomo"]
pprint(model.most_similar(positive_examples, negative_examples, top_n=1))
[('regina', 0.31)]

In accordo a questo principio, possiamo sperimentare le capacità di relazionalità semantica del modello che stiamo usando con alcuni altri esempi strutturalmente analoghi.

In [6]:
positive_examples = ["Roma", "Francia"]         # capitale di uno stato
negative_examples = ["Italia"]
print(model.most_similar(positive_examples, negative_examples))
[('Parigi', 0.48)]
In [11]:
positive_examples = ["Italia", "Catalogna"]     # stato di appartenenza di una regione
negative_examples = ["Lombardia"]
print(model.most_similar(positive_examples, negative_examples, top_n=5))
[('Spagna', 0.39), ('Barcellona', 0.37), ('Turchia', 0.34), ('Europa', 0.34), ('Barcelona', 0.33)]
In [12]:
positive_examples = ["Garibaldi", "Francia"]    # eroe nazionale
negative_examples = ["Italia"]
print(model.most_similar(positive_examples, negative_examples))
[('Bonaparte', 0.44)]
In [13]:
positive_examples = ["estate", "freddo"]        # stagione per temperatura 
negative_examples = ["caldo"]
print(model.most_similar(positive_examples, negative_examples))
[('inverno', 0.5)]
In [16]:
positive_examples = ["chitarra", "pianista"]    # strumento di un musicista
negative_examples = ["chitarrista"]
print(model.most_similar(positive_examples, negative_examples))
[('pianoforte', 0.63)]
In [7]:
positive_examples = ["nuoto", "palestra"]       # sport praticato in un luogo
negative_examples = ["piscina"]
print(model.most_similar(positive_examples, negative_examples, top_n=10))
[('ginnastica', 0.4), ('allenamento', 0.4), ('atletica', 0.38), ('fitness', 0.37), ('pallavolo', 0.36), ('ciclismo', 0.35), ('atleti', 0.35), ('Tennis', 0.34), ('yoga', 0.33), ('rugby', 0.33)]
In [16]:
positive_examples = ["due", "dieci"]              # numero successivo
negative_examples = ["uno"]
print(model.most_similar(positive_examples, negative_examples, top_n=5))
[('quattro', 0.56), ('cinque', 0.56), ('tre', 0.49), ('sette', 0.43), ('dodici', 0.41)]
In [19]:
positive_examples = ["padre", "figlia"]         # genitore per genere
negative_examples = ["figlio"]
print(model.most_similar(positive_examples, negative_examples))
[('madre', 0.56)]
In [13]:
positive_examples = ["attore", "donna"]         # femminile di un ruolo professionale
negative_examples = ["uomo"]
print(model.most_similar(positive_examples, negative_examples))
[('attrice', 0.63)]
In [20]:
positive_examples = ["bello", "cattivo"]        # opposto di un aggettivo
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.

In [21]:
positive_examples = ["bianca", "nero"]          # femminile di un aggettivo
negative_examples = ["bianco"]
print(model.most_similar(positive_examples, negative_examples))
[('nera', 0.56)]
In [22]:
positive_examples = ["treno", "automobili"]     # singolare di un sostantivo
negative_examples = ["treni"]
print(model.most_similar(positive_examples, negative_examples))
[('automobile', 0.54)]
In [22]:
positive_examples = ["andare", "guardato"]      # infinito di un verbo
negative_examples = ["andato"]
print(model.most_similar(positive_examples, negative_examples))
[('guardare', 0.57)]
In [24]:
positive_examples = ["pensando", "ascoltare"]   # gerundio di un verbo
negative_examples = ["pensare"]
print(model.most_similar(positive_examples, negative_examples))
[('ascoltando', 0.66)]