Trasformatori: come trasformano i tuoi dati?  |  di Maxime Wolf |  Marzo 2024

 | Intelligenza-Artificiale

Un tuffo nell'architettura dei Transformers e in ciò che li rende imbattibili nei compiti linguistici

Immagine dell'autore

Nel panorama in rapida evoluzione dell’intelligenza artificiale e dell’apprendimento automatico, un’innovazione si distingue per il suo profondo impatto sul modo in cui elaboriamo, comprendiamo e generiamo dati: Trasformatori. I trasformatori hanno rivoluzionato il campo dell'elaborazione del linguaggio naturale (NLP) e oltre, alimentando alcune delle applicazioni IA più avanzate di oggi. Ma cosa sono esattamente i Transformer e come riescono a trasformare i dati in modi così innovativi? Questo articolo demistifica il funzionamento interno dei modelli Transformer, concentrandosi su architettura dell'encoder. Inizieremo esaminando l'implementazione di un codificatore Transformer in Python, scomponendo i suoi componenti principali. Quindi, visualizzeremo come i Transformer elaborano e adattano i dati di input durante l'addestramento.

Sebbene questo blog non copra ogni dettaglio dell'architettura, fornisce un'implementazione e una comprensione generale del potere di trasformazione di Transformers. Per una spiegazione approfondita di Transformers ti consiglio di dare un'occhiata all'ottimo corso Stanford CS224-n.

Consiglio inoltre di seguire il Repositorio GitHub associato a questo articolo per ulteriori dettagli. 😊

Il modello Transformer di L'attenzione è tutto ciò di cui hai bisogno

Questa immagine mostra l'architettura originale di Transformer, che combina un codificatore e un decodificatore per attività linguistiche da sequenza a sequenza.

In questo articolo ci concentreremo sull'architettura dell'encoder (il blocco rosso nell'immagine). Questo è ciò che il popolare modello BERT utilizza sotto il cofano: l'attenzione è rivolta principalmente a comprendere e rappresentare i datipiuttosto che generare sequenze. Può essere utilizzato per una varietà di applicazioni: classificazione del testo, riconoscimento di entità denominate (NER), risposta a domande estrattive, ecc.

Quindi, come vengono effettivamente trasformati i dati da questa architettura? Spiegheremo ciascun componente in dettaglio, ma ecco una panoramica del processo.

  • Il testo di input è tokenizzato: la stringa Python viene trasformata in una lista di token (numeri)
  • Ogni token viene passato attraverso un Strato di incorporamento che restituisce una rappresentazione vettoriale per ciascun token
  • Gli incorporamenti vengono quindi ulteriormente codificati con a Livello di codifica posizionaleaggiungendo informazioni sulla posizione di ciascun token nella sequenza
  • Questi nuovi incorporamenti vengono trasformati da una serie di Livelli del codificatoreutilizzando un meccanismo di auto-attenzione
  • UN capo con compiti specifici può essere aggiunto. Ad esempio, utilizzeremo in seguito un'intestazione di classificazione per classificare le recensioni dei film come positive o negative

È importante capire che l'architettura Transformer trasforma i vettori di incorporamento mappandoli da una rappresentazione in uno spazio ad alta dimensione a un'altra all'interno dello stesso spazio, applicando una serie di trasformazioni complesse.

Il livello del codificatore di posizione

A differenza dei modelli RNN, il meccanismo dell’attenzione non fa uso dell’ordine della sequenza di input. La classe PositionalEncoder aggiunge codifiche posizionali agli incorporamenti di input, utilizzando due funzioni matematiche: coseno e seno.

Definizione della matrice di codifica posizionale da L'attenzione è tutto ciò di cui hai bisogno

Si noti che le codifiche posizionali non contengono parametri addestrabili: ci sono i risultati di calcoli deterministici, il che rende questo metodo molto trattabile. Inoltre, le funzioni seno e coseno assumono valori compresi tra -1 e 1 e hanno proprietà di periodicità utili per aiutare il modello ad apprendere modelli sulla posizioni relative delle parole.

class PositionalEncoder(nn.Module):
def __init__(self, d_model, max_length):
super(PositionalEncoder, self).__init__()
self.d_model = d_model
self.max_length = max_length

# Initialize the positional encoding matrix
pe = torch.zeros(max_length, d_model)

position = torch.arange(0, max_length, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float) * -(math.log(10000.0) / d_model))

# Calculate and assign position encodings to the matrix
pe(:, 0::2) = torch.sin(position * div_term)
pe(:, 1::2) = torch.cos(position * div_term)
self.pe = pe.unsqueeze(0)

def forward(self, x):
x = x + self.pe(:, :x.size(1)) # update embeddings
return x

Autoattenzione multi-testa

Il meccanismo di auto-attenzione è il componente chiave dell'architettura del codificatore. Ignoriamo per ora il “multitesta”. L'attenzione è un modo per determinare per ogni token (cioè ogni incorporamento) il rilevanza di tutti gli altri incorporamenti per quel tokenper ottenere una codifica più raffinata e contestualmente rilevante.

In che modo “esso” presta attenzione alle altre parole della sequenza? (Il trasformatore illustrato)

Ci sono 3 passaggi nel meccanismo di auto-attenzione.

  • Utilizzare le matrici Q, K e V per trasformare rispettivamente gli ingressi “domanda”, “chiave” E “valore”. Tieni presente che per l'autoattenzione, query, chiave e valori sono tutti uguali al nostro incorporamento di input
  • Calcola il punteggio di attenzione utilizzando la somiglianza del coseno (un prodotto scalare) tra domanda e il chiave. I punteggi vengono ridimensionati in base alla radice quadrata della dimensione di incorporamento per stabilizzare i gradienti durante l'addestramento
  • Utilizzare un livello softmax per ottenere questi punteggi probabilità
  • L'output è la media ponderata dei valoriutilizzando i punteggi di attenzione come pesi

Matematicamente ciò corrisponde alla seguente formula.

Il meccanismo dell'attenzione da L'attenzione è tutto ciò di cui hai bisogno

Cosa significa “multitesta”? Fondamentalmente, possiamo applicare il processo del meccanismo di auto-attenzione descritto più volte, in parallelo, e concatenare e proiettare gli output. Ciò consente a ciascuna testa di fconcentrarsi sui diversi aspetti semantici della frase.

Iniziamo definendo il numero di teste, la dimensione degli incastri (d_model) e la dimensione di ciascuna testa (head_dim). Inizializziamo anche le matrici Q, K e V (strati lineari) e lo strato di proiezione finale.

class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super(MultiHeadAttention, self).__init__()
self.num_heads = num_heads
self.d_model = d_model
self.head_dim = d_model // num_heads

self.query_linear = nn.Linear(d_model, d_model)
self.key_linear = nn.Linear(d_model, d_model)
self.value_linear = nn.Linear(d_model, d_model)
self.output_linear = nn.Linear(d_model, d_model)

Quando si utilizza l'attenzione multi-testa, applichiamo ciascuna testa di attenzione con una dimensione ridotta (head_dim invece di d_model) come nell'articolo originale, rendendo il costo computazionale totale simile a uno strato di attenzione a una testa con piena dimensionalità. Nota che questa è solo una divisione logica. Ciò che rende la multi-attenzione così potente è che può ancora essere rappresentata tramite una singola operazione a matrice, rendendo i calcoli molto efficienti sulle GPU.

def split_heads(self, x, batch_size):
# Split the sequence embeddings in x across the attention heads
x = x.view(batch_size, -1, self.num_heads, self.head_dim)
return x.permute(0, 2, 1, 3).contiguous().view(batch_size * self.num_heads, -1, self.head_dim)

Calcoliamo i punteggi di attenzione e utilizziamo una maschera per evitare di usare l'attenzione sui token imbottiti. Applichiamo un'attivazione softmax per creare queste probabilità di punteggio.

def compute_attention(self, query, key, mask=None):
# Compute dot-product attention scores
# dimensions of query and key are (batch_size * num_heads, seq_length, head_dim)
scores = query @ key.transpose(-2, -1) / math.sqrt(self.head_dim)
# Now, dimensions of scores is (batch_size * num_heads, seq_length, seq_length)
if mask is not None:
scores = scores.view(-1, scores.shape(0) // self.num_heads, mask.shape(1), mask.shape(2)) # for compatibility
scores = scores.masked_fill(mask == 0, float('-1e20')) # mask to avoid attention on padding tokens
scores = scores.view(-1, mask.shape(1), mask.shape(2)) # reshape back to original shape
# Normalize attention scores into attention weights
attention_weights = F.softmax(scores, dim=-1)

return attention_weights

L'attributo forward esegue la suddivisione logica multi-head e calcola i pesi dell'attenzione. Quindi, otteniamo l'output moltiplicando questi pesi per i valori. Infine, rimodelliamo l'output e lo proiettiamo con un livello lineare.

def forward(self, query, key, value, mask=None):
batch_size = query.size(0)

query = self.split_heads(self.query_linear(query), batch_size)
key = self.split_heads(self.key_linear(key), batch_size)
value = self.split_heads(self.value_linear(value), batch_size)

attention_weights = self.compute_attention(query, key, mask)

# Multiply attention weights by values, concatenate and linearly project outputs
output = torch.matmul(attention_weights, value)
output = output.view(batch_size, self.num_heads, -1, self.head_dim).permute(0, 2, 1, 3).contiguous().view(batch_size, -1, self.d_model)
return self.output_linear(output)

Il livello del codificatore

Questo è il componente principale dell'architettura, che sfrutta l'autoattenzione multi-testa. Per prima cosa implementiamo una semplice classe per eseguire un'operazione di feed-forward attraverso 2 strati densi.

class FeedForwardSubLayer(nn.Module):
def __init__(self, d_model, d_ff):
super(FeedForwardSubLayer, self).__init__()
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.relu = nn.ReLU()

def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))

Ora possiamo codificare la logica per il livello del codificatore. Iniziamo applicando l'autoattenzione all'input, che fornisce un vettore della stessa dimensione. Utilizziamo quindi la nostra mini rete feed-forward con i livelli Layer Norm. Tieni presente che utilizziamo anche Salta connessioni prima di applicare la normalizzazione.

class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout):
super(EncoderLayer, self).__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads)
self.feed_forward = FeedForwardSubLayer(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)

def forward(self, x, mask):
attn_output = self.self_attn(x, x, x, mask)
x = self.norm1(x + self.dropout(attn_output)) # skip connection and normalization
ff_output = self.feed_forward(x)
return self.norm2(x + self.dropout(ff_output)) # skip connection and normalization

Mettere tutto insieme

È ora di creare il nostro modello finale. Passiamo i nostri dati attraverso un livello di incorporamento. Questo trasforma i nostri token grezzi (interi) in un vettore numerico. Applichiamo quindi il nostro codificatore posizionale e diversi livelli di codificatore (num_layers).

class TransformerEncoder(nn.Module):
def __init__(self, vocab_size, d_model, num_layers, num_heads, d_ff, dropout, max_sequence_length):
super(TransformerEncoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.positional_encoding = PositionalEncoder(d_model, max_sequence_length)
self.layers = nn.ModuleList((EncoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)))

def forward(self, x, mask):
x = self.embedding(x)
x = self.positional_encoding(x)
for layer in self.layers:
x = layer(x, mask)
return x

Creiamo anche una classe ClassifierHead che viene utilizzata per trasformare l'incorporamento finale in probabilità di classe per il nostro compito di classificazione.

class ClassifierHead(nn.Module):
def __init__(self, d_model, num_classes):
super(ClassifierHead, self).__init__()
self.fc = nn.Linear(d_model, num_classes)

def forward(self, x):
logits = self.fc(x(:, 0, :)) # first token corresponds to the classification token
return F.softmax(logits, dim=-1)

Si noti che gli strati denso e softmax vengono applicati solo al primo incorporamento (corrispondente al primo token della nostra sequenza di input). Questo perché quando si tokenizza il testo, il primo token è il token (CLS) che sta per “classificazione”. Il token (CLS) è progettato per aggregare le informazioni dell'intera sequenza in un unico vettore di incorporamento, che funge da rappresentazione riepilogativa che può essere utilizzata per attività di classificazione.

Nota: il concetto di includere un token (CLS) ha origine da BERT, che inizialmente è stato addestrato su attività come la previsione della frase successiva. Il token (CLS) è stato inserito per prevedere la probabilità che la frase B segua la frase A, con un token (SEP) che separa le 2 frasi. Per il nostro modello, il token (SEP) segna semplicemente la fine della frase di input, come mostrato di seguito.

(CLS) Token nell'architettura BERT (Tutto sull'intelligenza artificiale)

Se ci pensi, è davvero strabiliante che questo singolo incorporamento (CLS) sia in grado di catturare così tante informazioni sull'intera sequenza, grazie alla capacità del meccanismo di auto-attenzione di soppesare e sintetizzare l'importanza di ogni parte del testo in relazione l'uno all'altro.

Si spera che la sezione precedente offra una migliore comprensione di come il nostro modello Transformer trasforma i dati di input. Ora scriveremo la nostra pipeline di formazione per la nostra attività di classificazione binaria utilizzando il set di dati IMDB (recensioni di film). Quindi, visualizzeremo l'incorporamento del token (CLS) durante il processo di addestramento per vedere come il nostro modello lo ha trasformato.

Per prima cosa definiamo i nostri iperparametri, nonché un tokenizzatore BERT. Nel repository GitHub, puoi vedere che ho anche codificato una funzione per selezionare un sottoinsieme del set di dati con solo 1200 treni e 200 esempi di test.

num_classes = 2 # binary classification
d_model = 256 # dimension of the embedding vectors
num_heads = 4 # number of heads for self-attention
num_layers = 4 # number of encoder layers
d_ff = 512. # dimension of the dense layers in the encoder layers
sequence_length = 256 # maximum sequence length
dropout = 0.4 # dropout to avoid overfitting
num_epochs = 20
batch_size = 32

loss_function = torch.nn.CrossEntropyLoss()

dataset = load_dataset("imdb")
dataset = balance_and_create_dataset(dataset, 1200, 200) # check GitHub repo

tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased', model_max_length=sequence_length)

Puoi provare a utilizzare il tokenizer BERT su una delle frasi:

print(tokenized_datasets('train')('input_ids')(0))

Ogni sequenza dovrebbe iniziare con il token 101, corrispondente a (CLS), seguito da alcuni numeri interi diversi da zero e riempito con zeri se la lunghezza della sequenza è inferiore a 256. Nota che questi zeri vengono ignorati durante il calcolo dell'auto-attenzione utilizzando il nostro ” maschera”.

tokenized_datasets = dataset.map(encode_examples, batched=True)
tokenized_datasets.set_format(type='torch', columns=('input_ids', 'attention_mask', 'label'))

train_dataloader = DataLoader(tokenized_datasets('train'), batch_size=batch_size, shuffle=True)
test_dataloader = DataLoader(tokenized_datasets('test'), batch_size=batch_size, shuffle=True)

vocab_size = tokenizer.vocab_size

encoder = TransformerEncoder(vocab_size, d_model, num_layers, num_heads, d_ff, dropout, max_sequence_length=sequence_length)
classifier = ClassifierHead(d_model, num_classes)

optimizer = torch.optim.Adam(list(encoder.parameters()) + list(classifier.parameters()), lr=1e-4)

Ora possiamo scrivere la nostra funzione treno:

def train(dataloader, encoder, classifier, optimizer, loss_function, num_epochs):
for epoch in range(num_epochs):
# Collect and store embeddings before each epoch starts for visualization purposes (check repo)
all_embeddings, all_labels = collect_embeddings(encoder, dataloader)
reduced_embeddings = visualize_embeddings(all_embeddings, all_labels, epoch, show=False)
dic_embeddings(epoch) = (reduced_embeddings, all_labels)

encoder.train()
classifier.train()
correct_predictions = 0
total_predictions = 0
for batch in tqdm(dataloader, desc="Training"):
input_ids = batch('input_ids')
attention_mask = batch('attention_mask') # indicate where padded tokens are
# These 2 lines make the attention_mask a matrix instead of a vector
attention_mask = attention_mask.unsqueeze(-1)
attention_mask = attention_mask & attention_mask.transpose(1, 2)
labels = batch('label')
optimizer.zero_grad()
output = encoder(input_ids, attention_mask)
classification = classifier(output)
loss = loss_function(classification, labels)
loss.backward()
optimizer.step()
preds = torch.argmax(classification, dim=1)
correct_predictions += torch.sum(preds == labels).item()
total_predictions += labels.size(0)

epoch_accuracy = correct_predictions / total_predictions
print(f'Epoch {epoch} Training Accuracy: {epoch_accuracy:.4f}')

Puoi trovare le funzioni collector_embeddings e visualize_embeddings nel repository GitHub. Memorizzano l'incorporamento del token (CLS) per ogni frase del set di addestramento, applicano una tecnica di riduzione della dimensionalità chiamata t-SNE per renderli vettori 2D (invece di vettori a 256 dimensioni) e salvano una trama animata.

Visualizziamo i risultati.

Incorporamenti previsti (CLS) per ciascun punto di allenamento (il blu corrisponde a frasi positive, il rosso corrisponde a frasi negative)

Osservando il grafico degli incorporamenti proiettati (CLS) per ciascun punto di training, possiamo vedere la chiara distinzione tra frasi positive (blu) e negative (rosse) dopo alcune epoche. Questa immagine mostra la notevole capacità dell'architettura Transformer di adattare gli incorporamenti nel tempo ed evidenzia la potenza del meccanismo di auto-attenzione. I dati vengono trasformati in modo tale che gli incorporamenti per ciascuna classe siano ben separati, semplificando così notevolmente il compito del capo classificatore.

Concludendo la nostra esplorazione dell'architettura Transformer, è evidente che questi modelli sono in grado di adattare i dati a una determinata attività. Con l'uso della codifica posizionale e dell'autoattenzione multi-testa, i Transformer vanno oltre la semplice elaborazione dei dati: interpretano e comprendono le informazioni con un livello di sofisticazione mai visto prima. La capacità di valutare dinamicamente la rilevanza delle diverse parti dei dati di input consente una comprensione e una rappresentazione più sfumata del testo di input. Ciò migliora le prestazioni in un'ampia gamma di attività downstream, tra cui la classificazione del testo, la risposta alle domande, il riconoscimento delle entità denominate e altro ancora.

Ora che hai una migliore comprensione dell'architettura del codificatore, sei pronto per approfondire i modelli di decodificatore e codificatore-decodificatore, che sono molto simili a quello che abbiamo appena esplorato. I decodificatori svolgono un ruolo fondamentale nelle attività generative e sono al centro dei popolari modelli GPT.

Fonte: towardsdatascience.com

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *