Crea un modello di linguaggio basato sulle tue chat di WhatsApp

Crea un modello di linguaggio basato sulle tue chat di WhatsApp Sviluppa il tuo Stile!

Una guida visiva all’architettura GPT con un’applicazione

Foto di Volodymyr Hryshchenko su Unsplash

I chatbot hanno indubbiamente trasformato la nostra interazione con le piattaforme digitali. Nonostante i progressi impressionanti nelle capacità dei modelli di linguaggio sottostanti nell’affrontare compiti complessi, l’esperienza utente spesso lascia a desiderare, sentendosi impersonale e distante.

Per rendere le conversazioni più attinenti, ho immaginato un chatbot che potesse emulare il mio stile di scrittura informale, simile a mandare messaggi a un amico su WhatsApp.

In questo articolo, ti guiderò nel mio percorso di costruzione di un (piccolo) modello linguistico che genera conversazioni sintetiche, utilizzando i messaggi di chat di WhatsApp come dati di input. Lungo il percorso, cercherò di svelare il funzionamento interno dell’architettura GPT in modo visivo e sperabilmente comprensibile, completato dall’effettiva implementazione in Python. Puoi trovare il progetto completo sul mio GitHub.

Nota: La classe di modello stessa è stata presa in larga misura dalla serie di video di Andrej Karpathy e adattata alle mie esigenze. Posso vivamente raccomandare i suoi tutorial.

lad-gpt

Addestra un modello linguistico da zero e interamente basato sui tuoi gruppi di chat di WhatsApp.

github.com

Indice

  1. Approccio selezionato
  2. Fonte dei dati
  3. Tokenizzazione
  4. Indicizzazione
  5. Architettura del modello
  6. Addestramento del modello
  7. Modalità chat

1. Approccio selezionato

Quando si tratta di adattare un modello linguistico a un corpus specifico di dati, ci sono diversi approcci possibili:

  1. Costruzione del modello: Questo implica la costruzione e l’addestramento di un modello da zero, offrendo la massima flessibilità in termini di architettura del modello e selezione dei dati di addestramento.
  2. Aggiustamento fine: Questo approccio sfrutta un modello pre-addestrato esistente, adattando i suoi pesi per allinearsi più strettamente con i dati specifici a disposizione.
  3. Ingegneria delle prompt: Anche questo utilizza un modello pre-addestrato esistente, ma qui il corpus unico viene direttamente incorporato nel prompt, senza modificare i pesi del modello.

Dato che la mia motivazione per questo progetto è principalmente di auto-formazione e sono piuttosto interessato all’architettura dei modelli linguistici moderni, ho optato per il primo approccio. Tuttavia, questa scelta comporta limitazioni ovvie. Data la dimensione dei miei dati e le risorse di calcolo disponibili, non mi aspettavo risultati all’altezza di modelli pre-addestrati all’avanguardia.

Tuttavia, ero fiducioso che il mio modello avrebbe colto alcuni interessanti modelli linguistici, cosa che alla fine ha fatto. Esplorare la seconda opzione (aggiustamento fine) potrebbe essere l’argomento di un futuro articolo.

2. Fonte dei dati

WhatsApp, il mio canale di comunicazione principale, è stata una fonte ideale per catturare il mio stile conversazionale. L’esportazione di oltre sei anni di cronologia dei gruppi di chat, per un totale di oltre 1,5 milioni di parole, è stata semplice.

I dati sono stati analizzati utilizzando un pattern regex in una lista di tuple contenenti la data, il nome del contatto e il messaggio della chat.

pattern = r'\[(.*?)\] (.*?): (.*)'matches = re.findall(pattern, text)text = [(x1, x2.lower()) for x0, x1, x2 in matches]

[    (2018-03-12 16:03:59, "Alice", "Ciao, come state ragazzi?"),    (2018-03-12 16:05:36, "Tom", "Io sto bene, grazie!"),    ...]

Ora, ogni elemento è stato processato singolarmente.

  • Data di invio: A parte la sua conversione in un oggetto datetime, non ho utilizzato queste informazioni. Tuttavia, si potrebbe guardare i delta di tempo per differenziare l’inizio e la fine delle discussioni sugli argomenti.
  • Nome del contatto: Durante la tokenizzazione del testo, ogni nome di contatto è considerato come un token unico. Ciò assicura che la combinazione di nome e cognome sia ancora considerata un’unica entità.
  • Messaggio della chat: Alla fine di ogni messaggio viene aggiunto un token speciale “<END>”.

3. Tokenizzazione

Per allenare un modello di linguaggio, dobbiamo suddividere il linguaggio in pezzi (chiamati token) e alimentarli nel modello in modo incrementale. La tokenizzazione può essere eseguita su diversi livelli.

  • Livello di carattere: Il testo viene considerato come una sequenza di singoli caratteri (compresi gli spazi vuoti). Questo approccio dettagliato consente di formare ogni possibile parola da una sequenza di caratteri. Tuttavia, è più difficile catturare le relazioni semantiche tra le parole.
  • Livello di parola: Il testo viene rappresentato come una sequenza di parole. Tuttavia, il vocabolario del modello è limitato dalle parole esistenti nei dati di allenamento.
  • Livello di sotto-parola: Il testo viene suddiviso in unità di sotto-parola, che sono più piccole delle parole ma più grandi dei singoli caratteri.

Anche se ho iniziato con un tokenizer a livello di carattere, ho avuto la sensazione che il tempo di addestramento venisse sprecato apprendendo sequenze di caratteri di parole ripetitive, anziché concentrarsi sulle relazioni semantiche tra le parole all’interno della frase.

Per una questione di semplicità concettuale, ho deciso di passare a un tokenizer a livello di parola, lasciando da parte le librerie disponibili per strategie di tokenizzazione più sofisticate.

from nltk.tokenize import RegexpTokenizerdef custom_tokenizer(txt: str, spec_tokens: List[str], pattern: str="|\d|\\w+|[^\\s]") -> List[str]:    """    Tokenizza il testo in parole o caratteri utilizzando il RegexpTokenizer di NLTK, considerando     determinate combinazioni speciali come token unici.    :param txt: Il corpus come un singolo elemento di stringa.    :param spec_tokens: Una lista di token speciali (ad esempio, fine, fuori dal vocabolario).    :param pattern: Di default il corpus viene tokenizzato a livello di parola (diviso dagli spazi).                    I numeri vengono considerati token singoli.    >> nota: Il pattern per la tokenizzazione a livello di carattere è '|.'    """    pattern = "|".join(spec_tokens) + pattern    tokenizer = RegexpTokenizer(pattern)    tokens = tokenizer.tokenize(txt)    return tokens

["Alice:", "Ciao", "come", "state", "?", "<END>", "Tom:", ... ]

È emerso che i miei dati di addestramento hanno un vocabolario di circa 70.000 parole uniche. Tuttavia, poiché molte parole compaiono solo una o due volte, ho deciso di sostituire tali parole rare con un token speciale “<UNK>”. Questo ha avuto l’effetto di ridurre il vocabolario a circa 25.000 parole, il che porta a un modello più piccolo che deve essere addestrato successivamente.

from collections import Counterdef get_infrequent_tokens(tokens: Union[List[str], str], min_count: int) -> List[str]:    """    Identifica i token che compaiono meno di una conta minima.        :param tokens: Se è il testo iniziale in una stringa, le frequenze vengono conteggiate a livello di carattere.                   Se è il corpus tokenizzato come lista, le frequenze vengono conteggiate a livello di token.    :min_count: Soglia di occorrenza per segnalare un token.    :return: Lista di token che appaiono raramente.     """    counts = Counter(tokens)    infreq_tokens = set([k for k,v in counts.items() if v<=min_count])    return infreq_tokensdef mask_tokens(tokens: List[str], mask: Set[str]) -> List[str]:    """    Itera attraverso tutti i token. Qualsiasi token che fa parte dell'insieme viene sostituito dal token sconosciuto.    :param tokens: Il corpus tokenizzato.    :param mask: Insieme di token che devono essere mascherati nel corpus.    :return: Lista di corpus tokenizzati dopo l'operazione di mascheramento.    """    return [t.replace(t, unknown_token) if t in mask else t for t in tokens]infreq_tokens = get_infrequent_tokens(tokens, min_count=2)tokens = mask_tokens(tokens, infreq_tokens)

["Alice:", "Ciao", "come", "state", "<UNK>", "?", "<END>", "Tom:", ... ]

4. Indicizzazione

Dopo la tokenizzazione, il passo successivo è convertire le parole e i token speciali in rappresentazioni numeriche. Utilizzando un elenco di vocabolario fisso, ogni parola viene indicizzata in base alla sua posizione. Le parole codificate vengono quindi preparate come tensori PyTorch.

import torchdef codifica(s: list, vocab: list) -> torch.tensor:    """    Codifica una lista di token in un tensore di interi, dato un vocabolario fisso.     Quando un token non viene trovato nel vocabolario, viene assegnato il token sconosciuto speciale.     Quando l'insieme di addestramento non ha utilizzato quel token speciale, viene assegnato un token casuale.    """    rand_token = random.randint(0, len(vocab))    mappa = {s:i per i,s in enumerate(vocab)}    enc = [mappa.get(c, mappa.get(token_sconosciuto, rand_token)) for c in s]    enc = torch.tensor(enc, dtype=torch.long)    return enc

torch.tensor([8127, 115, 2363, 3, ..., 14028])

Dato che abbiamo bisogno di valutare la qualità del nostro modello rispetto ad alcuni dati invisibili, dividiamo il tensore in due parti. E voilà, abbiamo i nostri set di addestramento e di validazione, pronti per essere alimentati al modello di linguaggio.

Immagine dell'autore

5. Architettura del Modello

Ho deciso di applicare l’architettura GPT, che è stata promossa dal famoso articolo “Attention is All You Need”. Poiché ho cercato di costruire un generatore di linguaggio e non un bot di domande e risposte, l’architettura con solo il decoder (lato destro) era sufficiente per questo scopo.

“Attention is All You Need” di A. Vaswani et. al. (Retrieved from arXiv: 1706.03762)

Nelle sezioni seguenti, analizzerò ogni componente dell’architettura GPT, spiegando il suo ruolo e le operazioni matriciali sottostanti. Partendo dal set di addestramento preparato, traccerò un esempio di contesto di 3 parole attraverso il modello, fino a ottenere una previsione del prossimo token.

5.1. Obiettivo del Modello

Prima di entrare nei dettagli tecnici, è fondamentale comprendere l’obiettivo principale del nostro modello. In un setup con solo il decoder, il nostro obiettivo è decodificare la struttura del linguaggio per prevedere accuratamente il prossimo token in una sequenza, dati i contesti dei token precedenti.

Immagine dell'autore

Mentre alimentiamo la sequenza di token indicizzati nel modello, subisce una serie di moltiplicazioni matriciali con varie matrici dei pesi. L’output è un vettore che rappresenta la probabilità di ogni token di essere il prossimo nella sequenza, in base al contesto di input.

Valutazione del Modello:

La performance del nostro modello viene valutata rispetto ai dati di addestramento, in cui è noto il prossimo token effettivo. L’obiettivo è massimizzare la probabilità di prevedere correttamente questo prossimo token.

Tuttavia, nell’apprendimento automatico, spesso ci concentriamo sul concetto di “loss”, che quantifica l’errore o la probabilità di previsioni errate. Per calcolarlo, confrontiamo le probabilità di output del modello con il prossimo token effettivo (utilizzando l’entropia incrociata).

Ottimizzazione:

Con una comprensione della nostra attuale perdita, miriamo a minimizzarla attraverso retropropagazione. Questo processo coinvolge l’alimentazione iterativa di sequenze di token nel modello e l’aggiustamento delle matrici dei pesi per migliorare le prestazioni.

In ogni figura, evidenzierò in giallo le matrici dei pesi che verranno ottimizzate durante quella procedura.

5.2. Iniezione di Output

Fino a questo punto, ogni token nella nostra sequenza è stato rappresentato da un indice intero. Tuttavia, questa forma semplicistica non riflette le relazioni o le somiglianze tra le parole. Per affrontare questo problema, eleviamo i nostri indici unidimensionali in spazi di dimensioni superiori attraverso l’inserimento.

  • Iniezione di parole: L’essenza di una parola è catturata da un vettore n-dimensionale di numeri decimali.
  • Iniezione posizionale: Queste iniezioni evidenziano l’importanza della posizione di una parola all’interno di una frase, rappresentate anch’esse come vettori n-dimensionali di numeri decimali.

Per ogni token, cerchiamo la sua iniezione di parole e iniezione posizionale e poi le sommiamo elemento per elemento. Questo risultato è l’iniezione di output di ogni token nel contesto.

Nell’esempio sottostante, il contesto è composto da 3 token. Alla fine del processo di inserimento, ogni token è rappresentato da un vettore n-dimensionale (dove n è la dimensione dell’iniezione, un iperparametro regolabile).

Immagine dell'autore

PyTorch offre classi specifiche per tali iniezioni. All’interno della nostra classe di modello, definiamo l’iniezione di parole e l’iniezione posizionale come segue (passando le dimensioni delle matrici come argomenti):

self.iniezione_parole = nn.Embedding(dim_vocab, dim_iniezione)self.iniezione_posizionale = nn.Embedding(dim_blocco, dim_iniezione)

5.3. Testa di Auto-Attenzione

Mentre le iniezioni di parole forniscono un senso generale di somiglianza tra parole, il vero significato di una parola spesso dipende dal contesto circostante. Ad esempio, “pipistrello” potrebbe riferirsi sia a un animale che a un attrezzo sportivo, a seconda della frase. Qui entra in gioco il meccanismo di auto-attenzione, un componente chiave dell’architettura GPT.

Il meccanismo di auto-attenzione si concentra su tre concetti principali: Query (Q), Key (K) e Value (V).

  1. Query (Q): La query è essenzialmente una rappresentazione del token corrente per il quale è necessario calcolare l’attenzione. È come se chiedessimo: “A cosa dovrei prestare attenzione nel contesto circostante come token corrente?”
  2. Chiavi (K): Le chiavi sono rappresentazioni di ogni token nella sequenza di input. Vengono accoppiate con la Query per determinare i punteggi di attenzione. Questo confronto misura quanto l’attenzione del token di query dovrebbe concentrarsi sugli altri token nel contesto. I punteggi elevati significano che dovrebbe essere prestata maggior attenzione.
  3. Valore (V): I valori sono anche rappresentazioni di ogni token nella sequenza di input. Tuttavia, il loro ruolo è diverso, in quanto applicano un ponderazione finale ai punteggi di attenzione.
Immagine dell'autore

Esempio:

Nel nostro esempio, ogni token del contesto è già in forma di iniezione, come vettori n-dimensionali (e1, e2, e3). La testa di auto-attenzione li usa come input per produrre una versione contestualizzata per ognuno di essi, uno alla volta.

  1. Quando viene valutato il token “name”, un vettore di query q viene ottenuto moltiplicando il suo vettore incorporato v2 con la matrice addestrabile M_Q.
  2. Allo stesso tempo, i vettori chiave (k1, k2, k3) vengono calcolati per ogni token nel contesto, moltiplicando ogni vettore incorporato (e1, e2, e3) con la matrice addestrabile M_K.
  3. I vettori valore (v1, v2, v3) vengono ottenuti nello stesso modo, ma moltiplicati da una matrice addestrabile diversa M_V.
  4. I punteggi di attenzione w vengono calcolati come prodotto scalare tra il vettore di query e ciascun vettore chiave separatamente.
  5. Infine impiliamo tutti i vettori valore in una matrice e moltiplichiamo quella per i punteggi di attenzione per ottenere il vettore contestualizzato per il token “name”.
class Head(nn.Module):    """    Questo modulo esegue operazioni di auto-attenzione sul tensore di input, producendo     un tensore di output con gli stessi passaggi temporali ma canali diversi.         :param head_size: La dimensione della testa nel meccanismo di attenzione multi-head.    """    def __init__(self, head_size):        super().__init__()        self.key = nn.Linear(embed_size, head_size, bias=False)        self.query = nn.Linear(embed_size, head_size, bias=False)        self.value = nn.Linear(embed_size, head_size, bias=False)    def forward(self, x):        """        # input di dimensione (batch, passaggi temporali, canali)        # output di dimensione (batch, passaggi temporali, dimensione della testa)        """        B,T,C = x.shape        k = self.key(x)                                             q = self.query(x)                                           # calcola i punteggi di attenzione        wei = q @ k.transpose(-2,-1)                                wei /= math.sqrt(k.shape[-1])                                       # evita previsioni future        tril = torch.tril(torch.ones(T, T))        wei = wei.masked_fill(tril == 0, float("-inf"))            wei = F.softmax(wei, dim=-1)                # aggregazione ponderata dei valori        v = self.value(x)            out = wei @ v        return out

5.4. Attenzione Multi-Head Mascherata

Il linguaggio è complesso e catturare tutte le sue sfumature non è semplice. Spesso un singolo set di calcoli di attenzione non è sufficiente per cogliere le sottilità di come le parole lavorano insieme. Ecco dove entra in gioco l’idea di attenzione multi-head nel modello GPT.

Pensa all’attenzione multi-head come avere diversi paia di occhi che guardano i dati in modi differenti, ognuno notando dettagli unici. Queste osservazioni separate vengono poi unite in una grande immagine. Per mantenere questa immagine gestibile e compatibile con il resto del nostro modello, utilizziamo uno strato lineare (con pesi addestrabili) per comprimerla di nuovo alla dimensione originale dell’embedding.

Infine, per assicurarci che il nostro modello non memorizzi solo i dati di addestramento ma diventi anche bravo a fare previsioni su nuovi testi, utilizziamo uno strato di dropout. Questo spegne casualmente parti dei dati durante l’addestramento, il che aiuta il modello ad adattarsi meglio.

Immagine dell'autore
class MultiHeadAttention(nn.Module):    """    Questa classe contiene più oggetti `Head`, che eseguono auto-attenzione     operazioni in parallelo.    """    def __init__(self):        super().__init__()        head_size = embed_size // n_heads        heads_list = [Head(head_size) for _ in range(n_heads)]                self.heads = nn.ModuleList(heads_list)        self.linear = nn.Linear(n_heads * head_size, embed_size)        self.dropout = nn.Dropout(dropout)    def forward(self, x):        heads_list = [h(x) for h in self.heads]        out = torch.cat(heads_list, dim=-1)        out = self.linear(out)        out = self.dropout(out)        return out

5.5. Feed Forward

Lo strato di attenzione multi-head cattura inizialmente le relazioni contestuali all’interno della sequenza. Viene aggiunta maggiore profondità alla rete tramite due strati lineari consecutivi, che costituiscono collettivamente la rete neurale feed-forward.

Immagine dell'autore

Nel primo strato lineare, aumentiamo la dimensionalità (di un fattore 4 nel nostro caso), che effettivamente allarga la capacità della rete di apprendere e rappresentare caratteristiche più complesse. Viene applicata una funzione ReLU su ogni elemento della matrice risultante, che consente di riconoscere modelli non lineari.

Successivamente, il secondo strato lineare agisce come compressore, riducendo le dimensioni espandibili alla forma originale (dimensione-block x dimensione dell’embedding). Uno strato di dropout conclude il processo, disattivando casualmente elementi della matrice, per il bene della generalizzazione del modello.

class FeedFoward(nn.Module):    """    Questo modulo fa passare il tensore di input attraverso una serie di trasformazioni lineari     e attivazioni non lineari.    """    def __init__(self):        super().__init__()        self.net = nn.Sequential(            nn.Linear(embed_size, 4 * embed_size),             nn.ReLU(),            nn.Linear(4 * embed_size, embed_size),            nn.Dropout(dropout),        )    def forward(self, x):        return self.net(x)

5.6. Aggiungi & Normalizza

Uniamo insieme il componente di attenzione multi-head e il componente di feed-forward, introducendo due elementi cruciali:

  • Connessioni Residuali (Add): Queste connessioni eseguono una somma elemento-per-elemento dell’output di un livello con il suo input non modificato. Durante l’addestramento, il modello regola l’importanza delle trasformazioni di ogni livello in base alla loro utilità. Se una trasformazione viene considerata non essenziale, i suoi pesi e di conseguenza l’output del livello tendono a zero. In questo caso viene comunque passato l’input non modificato attraverso la connessione residuale. Questa tecnica aiuta a mitigare il problema del gradiente che svanisce.
  • Normalizzazione di Livello (Norm): Questo metodo normalizza ogni vettore incorporato nel contesto sottraendo la sua media e dividendo per la sua deviazione standard. Questo processo assicura anche che i gradienti durante la retropropagazione né esplodano né svaniscano.
Immagine di autore

La catena di livelli di attenzione multi-head e feed-forward, collegati con “Aggiungi & Normalizza” viene consolidata in un blocco. Questo design modulare ci permette di formare una sequenza di blocchi. Il numero di questi blocchi è un iperparametro, che determina la profondità dell’architettura del modello.

class Block(nn.Module):    """    Questo modulo contiene un singolo blocco del transformer, che consiste in attenzione multi-head     seguita da reti neurali feed-forward.    """    def __init__(self):        super().__init__()        self.sa = MultiHeadAttention()        self.ffwd = FeedFoward()        self.ln1 = nn.LayerNorm(embed_size)        self.ln2 = nn.LayerNorm(embed_size)    def forward(self, x):        x = x + self.sa(self.ln1(x))        x = x + self.ffwd(self.ln2(x))        return x

5.7. Softmax

Dopo aver attraversato più componenti di blocco, otteniamo una matrice di dimensioni (dimensione del blocco x dimensione dell’embedding). Per ridimensionare questa matrice alle dimensioni richieste (dimensione del blocco x dimensione del vocabolario), la passiamo attraverso un ultimo livello lineare. Questa forma rappresenta una voce per ogni parola nel vocabolario in ogni posizione nel contesto.

Infine, applichiamo la trasformazione softmax a questi valori, convertendoli in probabilità. Abbiamo ottenuto con successo una distribuzione di probabilità per il prossimo token in ogni posizione nel contesto.

6. Addestramento del Modello

Per addestrare il modello di linguaggio, ho selezionato sequenze di token da posizioni casuali all’interno dei miei dati di addestramento. Data la natura frenetica delle conversazioni di WhatsApp, ho determinato che una lunghezza di contesto di 32 parole era sufficiente. Quindi, ho scelto pezzi casuali di 32 parole come input del contesto e ho usato i vettori corrispondenti, spostati di una parola, come target per il confronto.

Il processo di addestramento è stato eseguito attraverso i seguenti passaggi:

  1. Campiona più batch di contesto.
  2. Alimenta questi campioni nel modello per calcolare la perdita corrente.
  3. Applica la retropropagazione in base alla perdita corrente e ai pesi del modello.
  4. Valuta la perdita in modo più completo ogni 500esima iterazione.

Una volta che tutti gli altri iperparametri del modello (come dimensione dell’embedding, numero di testate di auto-attenzione, ecc.) sono stati fissati, ho finalizzato un modello con 2.5 milioni di parametri. Data la mia limitazione sulla dimensione dei dati di input e sulle risorse computazionali, ho trovato questa configurazione ottimale per me.

Il processo di addestramento ha richiesto circa 12 ore per 10,000 iterazioni. Si può notare che l’addestramento avrebbe potuto essere interrotto in precedenza, poiché la differenza tra la perdita sui set di validazione e di addestramento si allarga.

Immagine di autore

“`html

import jsonimport torchfrom config import eval_interval, learn_rate, max_itersfrom src.model import GPTLanguageModelfrom src.utils import current_time, estimate_loss, get_batchdef model_training(update: bool) -> None:    """    Trains or updates a GPTLanguageModel using pre-loaded data.    This function either initializes a new model or loads an existing model based    on the `update` parameter. It then trains the model using the AdamW optimizer    on the training and validation data sets. Finally the trained model is saved.    :param update: Boolean flag to indicate whether to update an existing model.    """    # LOAD DATA -----------------------------------------------------------------    train_data = torch.load("assets/output/train.pt")    valid_data = torch.load("assets/output/valid.pt")    with open("assets/output/vocab.txt", "r", encoding="utf-8") as f:        vocab = json.loads(f.read())    # INITIALIZE / LOAD MODEL ---------------------------------------------------    if update:        try:            model = torch.load("assets/models/model.pt")            print("Loaded existing model to continue training.")        except FileNotFoundError:            print("No existing model found. Initializing a new model.")            model = GPTLanguageModel(vocab_size=len(vocab))            else:        print("Initializing a new model.")        model = GPTLanguageModel(vocab_size=len(vocab))    # initialize optimizer    optimizer = torch.optim.AdamW(model.parameters(), lr=learn_rate)    # number of model parameters    n_params = sum(p.numel() for p in model.parameters())    print(f"Parameters to be optimized: {n_params}\n", )    # MODEL TRAINING ------------------------------------------------------------    for i in range(max_iters):        # evaluate the loss on train and valid sets every 'eval_interval' steps        if i % eval_interval == 0 or i == max_iters - 1:            train_loss = estimate_loss(model, train_data)            valid_loss = estimate_loss(model, valid_data)            time = current_time()            print(f"{time} | step {i}: train loss {train_loss:.4f}, valid loss {valid_loss:.4f}")        # sample batch of data        x_batch, y_batch = get_batch(train_data)        # evaluate the loss        logits, loss = model(x_batch, y_batch)        optimizer.zero_grad(set_to_none=True)        loss.backward()        optimizer.step()    torch.save(model, "assets/models/model.pt")    print("Model saved")

7. Chat-Mode

To interact with the trained model, I created a function that allows selecting a contact name via a dropdown menu and inputting a message for the model to respond to. The parameter “n_chats” determines the number of responses the model generates at once. The model concludes a generated message when it predicts the <END> token as the next token.

import jsonimport randomimport torchfrom prompt_toolkit import promptfrom prompt_toolkit.completion import WordCompleterfrom config import end_token, n_chatsfrom src.utils import custom_tokenizer, decode, encode, print_delayeddef conversation() -> None:    """    Emulates chat conversations by sampling from a pre-trained GPTLanguageModel.    This function loads a trained GPTLanguageModel along with vocabulary and     the list of special tokens. It then enters into a loop where the user specifies     a contact. Given this input, the model generates a sample response. The conversation     continues until the user inputs the end token.    """    with open("assets/output/vocab.txt", "r", encoding="utf-8") as f:        vocab = json.loads(f.read())    with open("assets/output/contacts.txt", "r", encoding="utf-8") as f:        contacts = json.loads(f.read())       spec_tokens = contacts + [end_token]    model = torch.load("assets/models/model.pt")    completer = WordCompleter(spec_tokens, ignore_case=True)        input = prompt("message >> ", completer=completer, default="")    output = torch.tensor([], dtype=torch.long)    print()    while input != end_token:        for _ in range(n_chats):            add_tokens = custom_tokenizer(input, spec_tokens)            add_context = encode(add_tokens, vocab)            context = torch.cat((output, add_context)).unsqueeze(1).T            n0 = len(output)            output = model.generate(context, vocab)            n1 = len(output)            print_delayed(decode(output[n0-n1:], vocab))            input = random.choice(contacts)        input = prompt("\nresponse >> ", completer=completer, default="")        print()

7. Chat-Mode

Per interagire con il modello addestrato, ho creato una funzione che consente di selezionare un nome del contatto tramite un menu a tendina e inserire un messaggio al modello per ottenere una risposta. Il parametro “n_chats” determina il numero di risposte generate contemporaneamente dal modello. Il modello conclude un messaggio generato quando prevede il token <END> come prossimo token.

import jsonimport randomimport torchfrom prompt_toolkit import promptfrom prompt_toolkit.completion import WordCompleterfrom config import end_token, n_chatsfrom src.utils import custom_tokenizer, decode, encode, print_delayeddef conversation() -> None:    """    Emula le conversazioni, campionando da un GPTLanguageModel pre-addestrato.    Questa funzione carica un GPTLanguageModel addestrato insieme al vocabolario e     alla lista dei token speciali. Entra quindi in un ciclo in cui l'utente specifica     un contatto. Basandosi su questo input, il modello genera una risposta campione. La conversazione     continua fino a quando l'utente inserisce il token di fine.    """    with open("assets/output/vocab.txt", "r", encoding="utf-8") as f:        vocab = json.loads(f.read())    with open("assets/output/contacts.txt", "r", encoding="utf-8") as f:        contacts = json.loads(f.read())       spec_tokens = contacts + [end_token]    model = torch.load("assets/models/model.pt")    completer = WordCompleter(spec_tokens, ignore_case=True)        input = prompt("messaggio >> ", completer=completer, default="")    output = torch.tensor([], dtype=torch.long)    print()    while input != end_token:        for _ in range(n_chats):            add_tokens = custom_tokenizer(input, spec_tokens)            add_context = encode(add_tokens, vocab)            context = torch.cat((output, add_context)).unsqueeze(1).T            n0 = len(output)            output = model.generate(context, vocab)            n1 = len(output)            print_delayed(decode(output[n0-n1:], vocab))            input = random.choice(contacts)        input = prompt("\nrisposta >> ", completer=completer, default="")        print()

“`

Conclusione:

A causa della privacy delle mie chat personali, non sono in grado di presentare qui esempi di prompt e conversazioni.

Tuttavia, ci si può aspettare che un modello di questa portata impari con successo la struttura generale delle frasi, producendo risultati significativi in termini di ordine delle parole. Nel mio caso, ha anche acquisito contesto per certi argomenti che erano prominenti nei dati di allenamento. Ad esempio, dato che le mie chat personali ruotano spesso attorno al tennis, i nomi dei giocatori di tennis e le parole legate al tennis venivano di solito messi insieme nell’output.

Tuttavia, valutando la coerenza delle frasi generate, devo ammettere che i risultati non hanno soddisfatto pienamente le mie già modeste aspettative. Ma naturalmente, potrei anche incolpare i miei amici per aver chiacchierato troppo senza senso, limitando la capacità dei modelli di imparare qualcosa di utile…

Per mostrare almeno qualche esempio di output alla fine, puoi vedere come si comporta il modello di prova su 200 messaggi di prova addestrati 😉

Immagine dell'autore