6. Pre-training & Loading models

Reading time: 24 minutes

Text Generation

Per addestrare un modello, è necessario che il modello sia in grado di generare nuovi token. Poi confronteremo i token generati con quelli attesi per addestrare il modello a imparare i token che deve generare.

Come negli esempi precedenti, abbiamo già previsto alcuni token, è possibile riutilizzare quella funzione per questo scopo.

tip

L'obiettivo di questa sesta fase è molto semplice: Addestrare il modello da zero. Per questo verrà utilizzata l'architettura LLM precedente con alcuni cicli sui set di dati utilizzando le funzioni di perdita e l'ottimizzatore definiti per addestrare tutti i parametri del modello.

Text Evaluation

Per eseguire un addestramento corretto è necessario misurare le previsioni ottenute per il token atteso. L'obiettivo dell'addestramento è massimizzare la probabilità del token corretto, il che implica aumentare la sua probabilità rispetto ad altri token.

Per massimizzare la probabilità del token corretto, i pesi del modello devono essere modificati affinché quella probabilità sia massimizzata. Gli aggiornamenti dei pesi avvengono tramite backpropagation. Questo richiede una funzione di perdita da massimizzare. In questo caso, la funzione sarà la differenza tra la previsione effettuata e quella desiderata.

Tuttavia, invece di lavorare con le previsioni grezze, si lavorerà con un logaritmo in base n. Quindi, se la previsione attuale del token atteso era 7.4541e-05, il logaritmo naturale (base e) di 7.4541e-05 è approssimativamente -9.5042.
Poi, per ogni voce con una lunghezza di contesto di 5 token, ad esempio, il modello dovrà prevedere 5 token, con i primi 4 token che sono gli ultimi dell'input e il quinto quello previsto. Pertanto, per ogni voce avremo 5 previsioni in quel caso (anche se i primi 4 erano nell'input, il modello non lo sa) con 5 token attesi e quindi 5 probabilità da massimizzare.

Pertanto, dopo aver eseguito il logaritmo naturale su ogni previsione, si calcola la media, si rimuove il simbolo meno (questo è chiamato cross entropy loss) e questo è il numero da ridurre il più vicino possibile a 0 perché il logaritmo naturale di 1 è 0:

https://camo.githubusercontent.com/3c0ab9c55cefa10b667f1014b6c42df901fa330bb2bc9cea88885e784daec8ba/68747470733a2f2f73656261737469616e72617363686b612e636f6d2f696d616765732f4c4c4d732d66726f6d2d736372617463682d696d616765732f636830355f636f6d707265737365642f63726f73732d656e74726f70792e776562703f313233

Un altro modo per misurare quanto sia buono il modello è chiamato perplexity. Perplexity è una metrica utilizzata per valutare quanto bene un modello di probabilità prevede un campione. Nella modellazione del linguaggio, rappresenta l'incertezza del modello quando prevede il prossimo token in una sequenza.
Ad esempio, un valore di perplexity di 48725 significa che quando deve prevedere un token non è sicuro su quale tra 48.725 token nel vocabolario sia quello giusto.

Pre-Train Example

Questo è il codice iniziale proposto in https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/ch05.ipynb alcune volte leggermente modificato

Codice precedente utilizzato qui ma già spiegato nelle sezioni precedenti
python
"""
This is code explained before so it won't be exaplained
"""

import tiktoken
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []

# Tokenize the entire text
token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})

# Use a sliding window to chunk the book into overlapping sequences of max_length
for i in range(0, len(token_ids) - max_length, stride):
input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i + 1: i + max_length + 1]
self.input_ids.append(torch.tensor(input_chunk))
self.target_ids.append(torch.tensor(target_chunk))

def __len__(self):
return len(self.input_ids)

def __getitem__(self, idx):
return self.input_ids[idx], self.target_ids[idx]


def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True, num_workers=0):
# Initialize the tokenizer
tokenizer = tiktoken.get_encoding("gpt2")

# Create dataset
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)

# Create dataloader
dataloader = DataLoader(
dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last, num_workers=num_workers)

return dataloader


class MultiHeadAttention(nn.Module):
def __init__(self, d_in, d_out, context_length, dropout, num_heads, qkv_bias=False):
super().__init__()
assert d_out % num_heads == 0, "d_out must be divisible by n_heads"

self.d_out = d_out
self.num_heads = num_heads
self.head_dim = d_out // num_heads  # Reduce the projection dim to match desired output dim

self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
self.out_proj = nn.Linear(d_out, d_out)  # Linear layer to combine head outputs
self.dropout = nn.Dropout(dropout)
self.register_buffer('mask', torch.triu(torch.ones(context_length, context_length), diagonal=1))

def forward(self, x):
b, num_tokens, d_in = x.shape

keys = self.W_key(x)  # Shape: (b, num_tokens, d_out)
queries = self.W_query(x)
values = self.W_value(x)

# We implicitly split the matrix by adding a `num_heads` dimension
# Unroll last dim: (b, num_tokens, d_out) -> (b, num_tokens, num_heads, head_dim)
keys = keys.view(b, num_tokens, self.num_heads, self.head_dim)
values = values.view(b, num_tokens, self.num_heads, self.head_dim)
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim)

# Transpose: (b, num_tokens, num_heads, head_dim) -> (b, num_heads, num_tokens, head_dim)
keys = keys.transpose(1, 2)
queries = queries.transpose(1, 2)
values = values.transpose(1, 2)

# Compute scaled dot-product attention (aka self-attention) with a causal mask
attn_scores = queries @ keys.transpose(2, 3)  # Dot product for each head

# Original mask truncated to the number of tokens and converted to boolean
mask_bool = self.mask.bool()[:num_tokens, :num_tokens]

# Use the mask to fill attention scores
attn_scores.masked_fill_(mask_bool, -torch.inf)

attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
attn_weights = self.dropout(attn_weights)

# Shape: (b, num_tokens, num_heads, head_dim)
context_vec = (attn_weights @ values).transpose(1, 2)

# Combine heads, where self.d_out = self.num_heads * self.head_dim
context_vec = context_vec.reshape(b, num_tokens, self.d_out)
context_vec = self.out_proj(context_vec)  # optional projection

return context_vec


class LayerNorm(nn.Module):
def __init__(self, emb_dim):
super().__init__()
self.eps = 1e-5
self.scale = nn.Parameter(torch.ones(emb_dim))
self.shift = nn.Parameter(torch.zeros(emb_dim))

def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * norm_x + self.shift


class GELU(nn.Module):
def __init__(self):
super().__init__()

def forward(self, x):
return 0.5 * x * (1 + torch.tanh(
torch.sqrt(torch.tensor(2.0 / torch.pi)) *
(x + 0.044715 * torch.pow(x, 3))
))


class FeedForward(nn.Module):
def __init__(self, cfg):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]),
GELU(),
nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]),
)

def forward(self, x):
return self.layers(x)


class TransformerBlock(nn.Module):
def __init__(self, cfg):
super().__init__()
self.att = MultiHeadAttention(
d_in=cfg["emb_dim"],
d_out=cfg["emb_dim"],
context_length=cfg["context_length"],
num_heads=cfg["n_heads"],
dropout=cfg["drop_rate"],
qkv_bias=cfg["qkv_bias"])
self.ff = FeedForward(cfg)
self.norm1 = LayerNorm(cfg["emb_dim"])
self.norm2 = LayerNorm(cfg["emb_dim"])
self.drop_shortcut = nn.Dropout(cfg["drop_rate"])

def forward(self, x):
# Shortcut connection for attention block
shortcut = x
x = self.norm1(x)
x = self.att(x)   # Shape [batch_size, num_tokens, emb_size]
x = self.drop_shortcut(x)
x = x + shortcut  # Add the original input back

# Shortcut connection for feed-forward block
shortcut = x
x = self.norm2(x)
x = self.ff(x)
x = self.drop_shortcut(x)
x = x + shortcut  # Add the original input back

return x


class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])

self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])

self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)

def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
x = tok_embeds + pos_embeds  # Shape [batch_size, num_tokens, emb_size]
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
python
# Download contents to train the data with
import os
import urllib.request

file_path = "the-verdict.txt"
url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt"

if not os.path.exists(file_path):
with urllib.request.urlopen(url) as response:
text_data = response.read().decode('utf-8')
with open(file_path, "w", encoding="utf-8") as file:
file.write(text_data)
else:
with open(file_path, "r", encoding="utf-8") as file:
text_data = file.read()

total_characters = len(text_data)
tokenizer = tiktoken.get_encoding("gpt2")
total_tokens = len(tokenizer.encode(text_data))

print("Data downloaded")
print("Characters:", total_characters)
print("Tokens:", total_tokens)

# Model initialization
GPT_CONFIG_124M = {
"vocab_size": 50257,   # Vocabulary size
"context_length": 256, # Shortened context length (orig: 1024)
"emb_dim": 768,        # Embedding dimension
"n_heads": 12,         # Number of attention heads
"n_layers": 12,        # Number of layers
"drop_rate": 0.1,      # Dropout rate
"qkv_bias": False      # Query-key-value bias
}

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.eval()
print ("Model initialized")


# Functions to transform from tokens to ids and from to ids to tokens
def text_to_token_ids(text, tokenizer):
encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'})
encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension
return encoded_tensor

def token_ids_to_text(token_ids, tokenizer):
flat = token_ids.squeeze(0) # remove batch dimension
return tokenizer.decode(flat.tolist())



# Define loss functions
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten())
return loss


def calc_loss_loader(data_loader, model, device, num_batches=None):
total_loss = 0.
if len(data_loader) == 0:
return float("nan")
elif num_batches is None:
num_batches = len(data_loader)
else:
# Reduce the number of batches to match the total number of batches in the data loader
# if num_batches exceeds the number of batches in the data loader
num_batches = min(num_batches, len(data_loader))
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
loss = calc_loss_batch(input_batch, target_batch, model, device)
total_loss += loss.item()
else:
break
return total_loss / num_batches


# Apply Train/validation ratio and create dataloaders
train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]

torch.manual_seed(123)

train_loader = create_dataloader_v1(
train_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=True,
shuffle=True,
num_workers=0
)

val_loader = create_dataloader_v1(
val_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=False,
shuffle=False,
num_workers=0
)


# Sanity checks
if total_tokens * (train_ratio) < GPT_CONFIG_124M["context_length"]:
print("Not enough tokens for the training loader. "
"Try to lower the `GPT_CONFIG_124M['context_length']` or "
"increase the `training_ratio`")

if total_tokens * (1-train_ratio) < GPT_CONFIG_124M["context_length"]:
print("Not enough tokens for the validation loader. "
"Try to lower the `GPT_CONFIG_124M['context_length']` or "
"decrease the `training_ratio`")

print("Train loader:")
for x, y in train_loader:
print(x.shape, y.shape)

print("\nValidation loader:")
for x, y in val_loader:
print(x.shape, y.shape)

train_tokens = 0
for input_batch, target_batch in train_loader:
train_tokens += input_batch.numel()

val_tokens = 0
for input_batch, target_batch in val_loader:
val_tokens += input_batch.numel()

print("Training tokens:", train_tokens)
print("Validation tokens:", val_tokens)
print("All tokens:", train_tokens + val_tokens)


# Indicate the device to use
if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
device = torch.device("mps")
else:
device = torch.device("cpu")

print(f"Using {device} device.")

model.to(device) # no assignment model = model.to(device) necessary for nn.Module classes



# Pre-calculate losses without starting yet
torch.manual_seed(123) # For reproducibility due to the shuffling in the data loader

with torch.no_grad(): # Disable gradient tracking for efficiency because we are not training, yet
train_loss = calc_loss_loader(train_loader, model, device)
val_loss = calc_loss_loader(val_loader, model, device)

print("Training loss:", train_loss)
print("Validation loss:", val_loss)


# Functions to train the data
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
eval_freq, eval_iter, start_context, tokenizer):
# Initialize lists to track losses and tokens seen
train_losses, val_losses, track_tokens_seen = [], [], []
tokens_seen, global_step = 0, -1

# Main training loop
for epoch in range(num_epochs):
model.train()  # Set model to training mode

for input_batch, target_batch in train_loader:
optimizer.zero_grad() # Reset loss gradients from previous batch iteration
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() # Calculate loss gradients
optimizer.step() # Update model weights using loss gradients
tokens_seen += input_batch.numel()
global_step += 1

# Optional evaluation step
if global_step % eval_freq == 0:
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
track_tokens_seen.append(tokens_seen)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")

# Print a sample text after each epoch
generate_and_print_sample(
model, tokenizer, device, start_context
)

return train_losses, val_losses, track_tokens_seen


def evaluate_model(model, train_loader, val_loader, device, eval_iter):
model.eval()
with torch.no_grad():
train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
model.train()
return train_loss, val_loss


def generate_and_print_sample(model, tokenizer, device, start_context):
model.eval()
context_size = model.pos_emb.weight.shape[0]
encoded = text_to_token_ids(start_context, tokenizer).to(device)
with torch.no_grad():
token_ids = generate_text(
model=model, idx=encoded,
max_new_tokens=50, context_size=context_size
)
decoded_text = token_ids_to_text(token_ids, tokenizer)
print(decoded_text.replace("\n", " "))  # Compact print format
model.train()


# Start training!
import time
start_time = time.time()

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)

num_epochs = 10
train_losses, val_losses, tokens_seen = train_model_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs, eval_freq=5, eval_iter=5,
start_context="Every effort moves you", tokenizer=tokenizer
)

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")



# Show graphics with the training process
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
import math
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
fig, ax1 = plt.subplots(figsize=(5, 3))
ax1.plot(epochs_seen, train_losses, label="Training loss")
ax1.plot(
epochs_seen, val_losses, linestyle="-.", label="Validation loss"
)
ax1.set_xlabel("Epochs")
ax1.set_ylabel("Loss")
ax1.legend(loc="upper right")
ax1.xaxis.set_major_locator(MaxNLocator(integer=True))
ax2 = ax1.twiny()
ax2.plot(tokens_seen, train_losses, alpha=0)
ax2.set_xlabel("Tokens seen")
fig.tight_layout()
plt.show()

# Compute perplexity from the loss values
train_ppls = [math.exp(loss) for loss in train_losses]
val_ppls = [math.exp(loss) for loss in val_losses]
# Plot perplexity over tokens seen
plt.figure()
plt.plot(tokens_seen, train_ppls, label='Training Perplexity')
plt.plot(tokens_seen, val_ppls, label='Validation Perplexity')
plt.xlabel('Tokens Seen')
plt.ylabel('Perplexity')
plt.title('Perplexity over Training')
plt.legend()
plt.show()

epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)


torch.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
},
"/tmp/model_and_optimizer.pth"
)

Funzioni per trasformare testo <--> ids

Queste sono alcune semplici funzioni che possono essere utilizzate per trasformare i testi dal vocabolario in ids e viceversa. Questo è necessario all'inizio della gestione del testo e alla fine delle previsioni:

python
# Functions to transform from tokens to ids and from to ids to tokens
def text_to_token_ids(text, tokenizer):
encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'})
encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension
return encoded_tensor

def token_ids_to_text(token_ids, tokenizer):
flat = token_ids.squeeze(0) # remove batch dimension
return tokenizer.decode(flat.tolist())

Funzioni di generazione del testo

In una sezione precedente è stata presentata una funzione che otteneva solo il token più probabile dopo aver ottenuto i logit. Tuttavia, questo significa che per ogni input verrà sempre generato lo stesso output, il che lo rende molto deterministico.

La seguente funzione generate_text applicherà i concetti di top-k, temperature e multinomial.

  • Il top-k significa che inizieremo a ridurre a -inf tutte le probabilità di tutti i token tranne i top k token. Quindi, se k=3, prima di prendere una decisione solo i 3 token più probabili avranno una probabilità diversa da -inf.
  • La temperature significa che ogni probabilità sarà divisa per il valore della temperatura. Un valore di 0.1 migliorerà la probabilità più alta rispetto a quella più bassa, mentre una temperatura di 5, ad esempio, la renderà più piatta. Questo aiuta a migliorare la variazione nelle risposte che vorremmo che l'LLM avesse.
  • Dopo aver applicato la temperatura, una funzione softmax viene applicata nuovamente per fare in modo che tutti i token rimanenti abbiano una probabilità totale di 1.
  • Infine, invece di scegliere il token con la probabilità più alta, la funzione multinomial viene applicata per prevedere il prossimo token in base alle probabilità finali. Quindi, se il token 1 aveva il 70% di probabilità, il token 2 il 20% e il token 3 il 10%, il 70% delle volte verrà selezionato il token 1, il 20% delle volte sarà il token 2 e il 10% delle volte sarà il token 3.
python
# Generate text function
def generate_text(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None):

# For-loop is the same as before: Get logits, and only focus on last time step
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:]
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :]

# New: Filter logits with top_k sampling
if top_k is not None:
# Keep only top_k values
top_logits, _ = torch.topk(logits, top_k)
min_val = top_logits[:, -1]
logits = torch.where(logits < min_val, torch.tensor(float("-inf")).to(logits.device), logits)

# New: Apply temperature scaling
if temperature > 0.0:
logits = logits / temperature

# Apply softmax to get probabilities
probs = torch.softmax(logits, dim=-1)  # (batch_size, context_len)

# Sample from the distribution
idx_next = torch.multinomial(probs, num_samples=1)  # (batch_size, 1)

# Otherwise same as before: get idx of the vocab entry with the highest logits value
else:
idx_next = torch.argmax(logits, dim=-1, keepdim=True)  # (batch_size, 1)

if idx_next == eos_id:  # Stop generating early if end-of-sequence token is encountered and eos_id is specified
break

# Same as before: append sampled index to the running sequence
idx = torch.cat((idx, idx_next), dim=1)  # (batch_size, num_tokens+1)

return idx

tip

Esiste un'alternativa comune a top-k chiamata top-p, nota anche come campionamento del nucleo, che invece di ottenere k campioni con la probabilità più alta, organizza tutto il vocabolario risultante per probabilità e sommando da quella con la probabilità più alta a quella più bassa fino a quando non si raggiunge una soglia.

Poi, solo quelle parole del vocabolario saranno considerate in base alle loro probabilità relative.

Questo consente di non dover selezionare un numero di campioni k, poiché il k ottimale potrebbe essere diverso in ogni caso, ma solo una soglia.

Nota che questo miglioramento non è incluso nel codice precedente.

tip

Un altro modo per migliorare il testo generato è utilizzare Beam search invece della ricerca greedy utilizzata in questo esempio.
A differenza della ricerca greedy, che seleziona la parola successiva più probabile a ogni passo e costruisce una singola sequenza, beam search tiene traccia delle k sequenze parziali con il punteggio più alto (chiamate "beams") a ogni passo. Esplorando più possibilità simultaneamente, bilancia efficienza e qualità, aumentando le possibilità di trovare una sequenza complessiva migliore che potrebbe essere persa dall'approccio greedy a causa di scelte subottimali precoci.

Nota che questo miglioramento non è incluso nel codice precedente.

Funzioni di perdita

La funzione calc_loss_batch calcola l'entropia incrociata di una previsione di un singolo batch.
La calc_loss_loader ottiene l'entropia incrociata di tutti i batch e calcola l'entropia incrociata media.

python
# Define loss functions
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten())
return loss

def calc_loss_loader(data_loader, model, device, num_batches=None):
total_loss = 0.
if len(data_loader) == 0:
return float("nan")
elif num_batches is None:
num_batches = len(data_loader)
else:
# Reduce the number of batches to match the total number of batches in the data loader
# if num_batches exceeds the number of batches in the data loader
num_batches = min(num_batches, len(data_loader))
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
loss = calc_loss_batch(input_batch, target_batch, model, device)
total_loss += loss.item()
else:
break
return total_loss / num_batches

tip

Gradient clipping è una tecnica utilizzata per migliorare la stabilità dell'addestramento in grandi reti neurali impostando una soglia massima per le magnitudini dei gradienti. Quando i gradienti superano questo max_norm predefinito, vengono ridotti proporzionalmente per garantire che gli aggiornamenti ai parametri del modello rimangano all'interno di un intervallo gestibile, prevenendo problemi come i gradienti esplosivi e garantendo un addestramento più controllato e stabile.

Nota che questo miglioramento non è incluso nel codice precedente.

Controlla il seguente esempio:

Caricamento Dati

Le funzioni create_dataloader_v1 e create_dataloader_v1 sono già state discusse in una sezione precedente.

Da qui nota come sia definito che il 90% del testo sarà utilizzato per l'addestramento mentre il 10% sarà utilizzato per la validazione e entrambi i set sono memorizzati in 2 diversi data loader.
Nota che a volte parte del set di dati è anche lasciata per un set di test per valutare meglio le prestazioni del modello.

Entrambi i data loader utilizzano la stessa dimensione del batch, lunghezza massima, stride e num workers (0 in questo caso).
Le principali differenze sono i dati utilizzati da ciascuno, e il validatore non scarta l'ultimo né mescola i dati poiché non è necessario per scopi di validazione.

Inoltre, il fatto che lo stride sia grande quanto la lunghezza del contesto significa che non ci sarà sovrapposizione tra i contesti utilizzati per addestrare i dati (riduce l'overfitting ma anche il set di dati di addestramento).

Inoltre, nota che la dimensione del batch in questo caso è 2 per dividere i dati in 2 batch, l'obiettivo principale di questo è consentire l'elaborazione parallela e ridurre il consumo per batch.

python
train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]

torch.manual_seed(123)

train_loader = create_dataloader_v1(
train_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=True,
shuffle=True,
num_workers=0
)

val_loader = create_dataloader_v1(
val_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=False,
shuffle=False,
num_workers=0
)

Controlli di Sanità

L'obiettivo è verificare che ci siano abbastanza token per l'addestramento, che le forme siano quelle attese e ottenere alcune informazioni sul numero di token utilizzati per l'addestramento e per la validazione:

python
# Sanity checks
if total_tokens * (train_ratio) < GPT_CONFIG_124M["context_length"]:
print("Not enough tokens for the training loader. "
"Try to lower the `GPT_CONFIG_124M['context_length']` or "
"increase the `training_ratio`")

if total_tokens * (1-train_ratio) < GPT_CONFIG_124M["context_length"]:
print("Not enough tokens for the validation loader. "
"Try to lower the `GPT_CONFIG_124M['context_length']` or "
"decrease the `training_ratio`")

print("Train loader:")
for x, y in train_loader:
print(x.shape, y.shape)

print("\nValidation loader:")
for x, y in val_loader:
print(x.shape, y.shape)

train_tokens = 0
for input_batch, target_batch in train_loader:
train_tokens += input_batch.numel()

val_tokens = 0
for input_batch, target_batch in val_loader:
val_tokens += input_batch.numel()

print("Training tokens:", train_tokens)
print("Validation tokens:", val_tokens)
print("All tokens:", train_tokens + val_tokens)

Seleziona dispositivo per addestramento e pre-calcoli

Il seguente codice seleziona semplicemente il dispositivo da utilizzare e calcola una perdita di addestramento e una perdita di validazione (senza aver ancora addestrato nulla) come punto di partenza.

python
# Indicate the device to use

if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
device = torch.device("mps")
else:
device = torch.device("cpu")

print(f"Using {device} device.")

model.to(device) # no assignment model = model.to(device) necessary for nn.Module classes

# Pre-calculate losses without starting yet
torch.manual_seed(123) # For reproducibility due to the shuffling in the data loader

with torch.no_grad(): # Disable gradient tracking for efficiency because we are not training, yet
train_loss = calc_loss_loader(train_loader, model, device)
val_loss = calc_loss_loader(val_loader, model, device)

print("Training loss:", train_loss)
print("Validation loss:", val_loss)

Funzioni di addestramento

La funzione generate_and_print_sample prenderà semplicemente un contesto e genererà alcuni token per avere un'idea di quanto sia buona il modello a quel punto. Questa viene chiamata da train_model_simple ad ogni passo.

La funzione evaluate_model viene chiamata con la stessa frequenza indicata alla funzione di addestramento ed è utilizzata per misurare la perdita di addestramento e la perdita di validazione a quel punto nell'addestramento del modello.

Poi la grande funzione train_model_simple è quella che effettivamente addestra il modello. Si aspetta:

  • Il caricatore di dati di addestramento (con i dati già separati e preparati per l'addestramento)
  • Il caricatore di validazione
  • L'ottimizzatore da utilizzare durante l'addestramento: Questa è la funzione che utilizzerà i gradienti e aggiornerà i parametri per ridurre la perdita. In questo caso, come vedrai, viene utilizzato AdamW, ma ce ne sono molti altri.
  • optimizer.zero_grad() viene chiamato per resettare i gradienti ad ogni round per non accumularli.
  • Il parametro lr è il tasso di apprendimento che determina la dimensione dei passi effettuati durante il processo di ottimizzazione quando si aggiornano i parametri del modello. Un tasso di apprendimento più piccolo significa che l'ottimizzatore effettua aggiornamenti più piccoli ai pesi, il che può portare a una convergenza più precisa ma potrebbe rallentare l'addestramento. Un tasso di apprendimento più grande può accelerare l'addestramento ma rischia di superare il minimo della funzione di perdita (saltare oltre il punto in cui la funzione di perdita è minimizzata).
  • Weight Decay modifica il passo di Calcolo della Perdita aggiungendo un termine extra che penalizza i pesi grandi. Questo incoraggia l'ottimizzatore a trovare soluzioni con pesi più piccoli, bilanciando tra l'adattamento ai dati e il mantenimento del modello semplice, prevenendo l'overfitting nei modelli di machine learning scoraggiando il modello dall'assegnare troppa importanza a qualsiasi singola caratteristica.
  • Gli ottimizzatori tradizionali come SGD con regolarizzazione L2 accoppiano il weight decay con il gradiente della funzione di perdita. Tuttavia, AdamW (una variante dell'ottimizzatore Adam) decouples il weight decay dall'aggiornamento del gradiente, portando a una regolarizzazione più efficace.
  • Il dispositivo da utilizzare per l'addestramento
  • Il numero di epoche: Numero di volte per passare sui dati di addestramento
  • La frequenza di valutazione: La frequenza per chiamare evaluate_model
  • L'iterazione di valutazione: Il numero di batch da utilizzare quando si valuta lo stato attuale del modello quando si chiama generate_and_print_sample
  • Il contesto di partenza: Quale frase iniziale utilizzare quando si chiama generate_and_print_sample
  • Il tokenizer
python
# Functions to train the data
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
eval_freq, eval_iter, start_context, tokenizer):
# Initialize lists to track losses and tokens seen
train_losses, val_losses, track_tokens_seen = [], [], []
tokens_seen, global_step = 0, -1

# Main training loop
for epoch in range(num_epochs):
model.train()  # Set model to training mode

for input_batch, target_batch in train_loader:
optimizer.zero_grad() # Reset loss gradients from previous batch iteration
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() # Calculate loss gradients
optimizer.step() # Update model weights using loss gradients
tokens_seen += input_batch.numel()
global_step += 1

# Optional evaluation step
if global_step % eval_freq == 0:
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
track_tokens_seen.append(tokens_seen)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")

# Print a sample text after each epoch
generate_and_print_sample(
model, tokenizer, device, start_context
)

return train_losses, val_losses, track_tokens_seen


def evaluate_model(model, train_loader, val_loader, device, eval_iter):
model.eval() # Set in eval mode to avoid dropout
with torch.no_grad():
train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
model.train() # Back to training model applying all the configurations
return train_loss, val_loss


def generate_and_print_sample(model, tokenizer, device, start_context):
model.eval() # Set in eval mode to avoid dropout
context_size = model.pos_emb.weight.shape[0]
encoded = text_to_token_ids(start_context, tokenizer).to(device)
with torch.no_grad():
token_ids = generate_text(
model=model, idx=encoded,
max_new_tokens=50, context_size=context_size
)
decoded_text = token_ids_to_text(token_ids, tokenizer)
print(decoded_text.replace("\n", " "))  # Compact print format
model.train() # Back to training model applying all the configurations

tip

Per migliorare il tasso di apprendimento ci sono un paio di tecniche rilevanti chiamate linear warmup e cosine decay.

Linear warmup consiste nel definire un tasso di apprendimento iniziale e uno massimo e aggiornarlo costantemente dopo ogni epoca. Questo perché iniziare l'addestramento con aggiornamenti di peso più piccoli riduce il rischio che il modello incontri aggiornamenti grandi e destabilizzanti durante la fase di addestramento.
Cosine decay è una tecnica che riduce gradualmente il tasso di apprendimento seguendo una curva a mezza coseno dopo la fase di warmup, rallentando gli aggiornamenti di peso per minimizzare il rischio di superare i minimi di perdita e garantire stabilità nell'addestramento nelle fasi successive.

Nota che questi miglioramenti non sono inclusi nel codice precedente.

Inizia l'addestramento

python
import time
start_time = time.time()

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)

num_epochs = 10
train_losses, val_losses, tokens_seen = train_model_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs, eval_freq=5, eval_iter=5,
start_context="Every effort moves you", tokenizer=tokenizer
)

end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")

Stampa l'evoluzione dell'addestramento

Con la seguente funzione è possibile stampare l'evoluzione del modello mentre veniva addestrato.

python
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
import math
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
fig, ax1 = plt.subplots(figsize=(5, 3))
ax1.plot(epochs_seen, train_losses, label="Training loss")
ax1.plot(
epochs_seen, val_losses, linestyle="-.", label="Validation loss"
)
ax1.set_xlabel("Epochs")
ax1.set_ylabel("Loss")
ax1.legend(loc="upper right")
ax1.xaxis.set_major_locator(MaxNLocator(integer=True))
ax2 = ax1.twiny()
ax2.plot(tokens_seen, train_losses, alpha=0)
ax2.set_xlabel("Tokens seen")
fig.tight_layout()
plt.show()

# Compute perplexity from the loss values
train_ppls = [math.exp(loss) for loss in train_losses]
val_ppls = [math.exp(loss) for loss in val_losses]
# Plot perplexity over tokens seen
plt.figure()
plt.plot(tokens_seen, train_ppls, label='Training Perplexity')
plt.plot(tokens_seen, val_ppls, label='Validation Perplexity')
plt.xlabel('Tokens Seen')
plt.ylabel('Perplexity')
plt.title('Perplexity over Training')
plt.legend()
plt.show()

epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)

Salva il modello

È possibile salvare il modello + ottimizzatore se si desidera continuare l'addestramento in seguito:

python
# Save the model and the optimizer for later training
torch.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
},
"/tmp/model_and_optimizer.pth"
)
# Note that this model with the optimizer occupied close to 2GB

# Restore model and optimizer for training
checkpoint = torch.load("/tmp/model_and_optimizer.pth", map_location=device)

model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train(); # Put in training mode

O solo il modello se hai intenzione di usarlo solo:

python
# Save the model
torch.save(model.state_dict(), "model.pth")

# Load it
model = GPTModel(GPT_CONFIG_124M)

model.load_state_dict(torch.load("model.pth", map_location=device))

model.eval() # Put in eval mode

Caricamento dei pesi di GPT2

Ci sono 2 script rapidi per caricare i pesi di GPT2 localmente. Per entrambi puoi clonare il repository https://github.com/rasbt/LLMs-from-scratch localmente, poi:

Riferimenti