6. Pre-entrenamiento y carga de modelos
Reading time: 25 minutes
Generación de texto
Para entrenar un modelo, necesitaremos que ese modelo sea capaz de generar nuevos tokens. Luego compararemos los tokens generados con los esperados para entrenar al modelo en aprender los tokens que necesita generar.
Como en los ejemplos anteriores ya predijimos algunos tokens, es posible reutilizar esa función para este propósito.
tip
El objetivo de esta sexta fase es muy simple: Entrenar el modelo desde cero. Para esto se utilizará la arquitectura LLM anterior con algunos bucles que recorren los conjuntos de datos utilizando las funciones de pérdida y el optimizador definidos para entrenar todos los parámetros del modelo.
Evaluación de texto
Para realizar un entrenamiento correcto es necesario medir las predicciones obtenidas para el token esperado. El objetivo del entrenamiento es maximizar la probabilidad del token correcto, lo que implica aumentar su probabilidad en relación con otros tokens.
Para maximizar la probabilidad del token correcto, se deben modificar los pesos del modelo para que esa probabilidad sea maximizada. Las actualizaciones de los pesos se realizan a través de retropropagación. Esto requiere una función de pérdida a maximizar. En este caso, la función será la diferencia entre la predicción realizada y la deseada.
Sin embargo, en lugar de trabajar con las predicciones en bruto, se trabajará con un logaritmo de base n. Así que si la predicción actual del token esperado era 7.4541e-05, el logaritmo natural (base e) de 7.4541e-05 es aproximadamente -9.5042.
Luego, para cada entrada con una longitud de contexto de 5 tokens, por ejemplo, el modelo necesitará predecir 5 tokens, siendo los primeros 4 tokens el último de la entrada y el quinto el predicho. Por lo tanto, para cada entrada tendremos 5 predicciones en ese caso (incluso si los primeros 4 estaban en la entrada, el modelo no sabe esto) con 5 tokens esperados y, por lo tanto, 5 probabilidades a maximizar.
Por lo tanto, después de realizar el logaritmo natural a cada predicción, se calcula el promedio, se elimina el símbolo menos (esto se llama pérdida de entropía cruzada) y ese es el número que se debe reducir lo más cerca posible de 0 porque el logaritmo natural de 1 es 0:
 (1).png)
Otra forma de medir qué tan bueno es el modelo se llama perplexity. Perplexity es una métrica utilizada para evaluar qué tan bien un modelo de probabilidad predice una muestra. En el modelado del lenguaje, representa la incertidumbre del modelo al predecir el siguiente token en una secuencia.
Por ejemplo, un valor de perplexity de 48725 significa que, al necesitar predecir un token, no está seguro de cuál entre 48,725 tokens en el vocabulario es el correcto.
Ejemplo de Pre-entrenamiento
Este es el código inicial propuesto en https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/ch05.ipynb algunas veces ligeramente modificado
Código anterior utilizado aquí pero ya explicado en secciones anteriores
"""
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
# 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"
)
Funciones para transformar texto <--> ids
Estas son algunas funciones simples que se pueden usar para transformar textos del vocabulario a ids y viceversa. Esto es necesario al principio del manejo del texto y al final de las predicciones:
# 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())
Generar funciones de texto
En una sección anterior, se presentó una función que solo obtenía el token más probable después de obtener los logits. Sin embargo, esto significará que para cada entrada siempre se generará la misma salida, lo que lo hace muy determinista.
La siguiente función generate_text
aplicará los conceptos de top-k
, temperature
y multinomial
.
- El
top-k
significa que comenzaremos a reducir a-inf
todas las probabilidades de todos los tokens excepto los k tokens principales. Así que, si k=3, antes de tomar una decisión, solo los 3 tokens más probables tendrán una probabilidad diferente de-inf
. - La
temperature
significa que cada probabilidad se dividirá por el valor de temperatura. Un valor de0.1
mejorará la probabilidad más alta en comparación con la más baja, mientras que una temperatura de5
, por ejemplo, la hará más plana. Esto ayuda a mejorar la variación en las respuestas que nos gustaría que tuviera el LLM. - Después de aplicar la temperatura, se aplica nuevamente una función
softmax
para que todos los tokens restantes tengan una probabilidad total de 1. - Finalmente, en lugar de elegir el token con la mayor probabilidad, se aplica la función
multinomial
para predecir el siguiente token de acuerdo con las probabilidades finales. Así que si el token 1 tenía un 70% de probabilidades, el token 2 un 20% y el token 3 un 10%, el 70% de las veces se seleccionará el token 1, el 20% de las veces será el token 2 y el 10% de las veces será el token 3.
# 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
Hay una alternativa común a top-k
llamada top-p
, también conocida como muestreo de núcleo, que en lugar de obtener k muestras con la mayor probabilidad, organiza todo el vocabulario resultante por probabilidades y suma desde la probabilidad más alta hasta la más baja hasta que se alcance un umbral.
Luego, solo esas palabras del vocabulario se considerarán de acuerdo con sus probabilidades relativas.
Esto permite no necesitar seleccionar un número de k
muestras, ya que el k óptimo podría ser diferente en cada caso, sino solo un umbral.
Tenga en cuenta que esta mejora no está incluida en el código anterior.
tip
Otra forma de mejorar el texto generado es utilizando Beam search en lugar de la búsqueda codiciosa utilizada en este ejemplo.
A diferencia de la búsqueda codiciosa, que selecciona la palabra siguiente más probable en cada paso y construye una única secuencia, beam search realiza un seguimiento de las 𝑘 k secuencias parciales con mayor puntuación (llamadas "beams") en cada paso. Al explorar múltiples posibilidades simultáneamente, equilibra eficiencia y calidad, aumentando las posibilidades de encontrar una mejor secuencia que podría ser pasada por alto por el enfoque codicioso debido a elecciones tempranas y subóptimas.
Tenga en cuenta que esta mejora no está incluida en el código anterior.
Funciones de pérdida
La función calc_loss_batch
calcula la entropía cruzada de una predicción de un solo lote.
La calc_loss_loader
obtiene la entropía cruzada de todos los lotes y calcula la entropía cruzada promedio.
# 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 es una técnica utilizada para mejorar la estabilidad del entrenamiento en grandes redes neuronales al establecer un umbral máximo para las magnitudes de los gradientes. Cuando los gradientes superan este max_norm
predefinido, se reducen proporcionalmente para asegurar que las actualizaciones a los parámetros del modelo se mantengan dentro de un rango manejable, previniendo problemas como gradientes explosivos y asegurando un entrenamiento más controlado y estable.
Nota que esta mejora no está incluida en el código anterior.
Consulta el siguiente ejemplo:
 (1).png)
Cargando Datos
Las funciones create_dataloader_v1
y create_dataloader_v1
ya fueron discutidas en una sección anterior.
Desde aquí, nota cómo se define que el 90% del texto se utilizará para el entrenamiento mientras que el 10% se utilizará para la validación y ambos conjuntos se almacenan en 2 cargadores de datos diferentes.
Nota que a veces parte del conjunto de datos también se deja para un conjunto de prueba para evaluar mejor el rendimiento del modelo.
Ambos cargadores de datos están utilizando el mismo tamaño de lote, longitud máxima, y stride y num workers (0 en este caso).
Las principales diferencias son los datos utilizados por cada uno, y el validador no está descartando el último ni barajando los datos ya que no es necesario para fines de validación.
Además, el hecho de que el stride sea tan grande como la longitud del contexto, significa que no habrá superposición entre los contextos utilizados para entrenar los datos (reduce el sobreajuste pero también el conjunto de datos de entrenamiento).
Además, nota que el tamaño del lote en este caso es 2 para dividir los datos en 2 lotes, el objetivo principal de esto es permitir el procesamiento paralelo y reducir el consumo por lote.
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
)
Comprobaciones de Consistencia
El objetivo es verificar que hay suficientes tokens para el entrenamiento, que las formas son las esperadas y obtener información sobre el número de tokens utilizados para el entrenamiento y para la validación:
# 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)
Seleccionar dispositivo para entrenamiento y pre-cálculos
El siguiente código solo selecciona el dispositivo a utilizar y calcula una pérdida de entrenamiento y una pérdida de validación (sin haber entrenado nada aún) como punto de partida.
# 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)
Funciones de entrenamiento
La función generate_and_print_sample
solo obtendrá un contexto y generará algunos tokens para tener una idea de cuán bueno es el modelo en ese momento. Esto es llamado por train_model_simple
en cada paso.
La función evaluate_model
se llama con la frecuencia indicada a la función de entrenamiento y se utiliza para medir la pérdida de entrenamiento y la pérdida de validación en ese momento del entrenamiento del modelo.
Luego, la gran función train_model_simple
es la que realmente entrena el modelo. Espera:
- El cargador de datos de entrenamiento (con los datos ya separados y preparados para el entrenamiento)
- El cargador de validación
- El optimizador a utilizar durante el entrenamiento: Esta es la función que utilizará los gradientes y actualizará los parámetros para reducir la pérdida. En este caso, como verás, se utiliza
AdamW
, pero hay muchos más. - Se llama a
optimizer.zero_grad()
para restablecer los gradientes en cada ronda y no acumularlos. - El parámetro
lr
es la tasa de aprendizaje que determina el tamaño de los pasos dados durante el proceso de optimización al actualizar los parámetros del modelo. Una tasa de aprendizaje más pequeña significa que el optimizador realiza actualizaciones más pequeñas a los pesos, lo que puede llevar a una convergencia más precisa pero puede ralentizar el entrenamiento. Una tasa de aprendizaje más grande puede acelerar el entrenamiento pero riesga sobrepasar el mínimo de la función de pérdida (saltar sobre el punto donde se minimiza la función de pérdida). - Weight Decay modifica el paso de Cálculo de Pérdida al agregar un término extra que penaliza los pesos grandes. Esto anima al optimizador a encontrar soluciones con pesos más pequeños, equilibrando entre ajustar bien los datos y mantener el modelo simple, previniendo el sobreajuste en modelos de aprendizaje automático al desincentivar al modelo de asignar demasiada importancia a cualquier característica única.
- Los optimizadores tradicionales como SGD con regularización L2 acoplan el weight decay con el gradiente de la función de pérdida. Sin embargo, AdamW (una variante del optimizador Adam) desacopla el weight decay de la actualización del gradiente, lo que lleva a una regularización más efectiva.
- El dispositivo a utilizar para el entrenamiento
- El número de épocas: Número de veces que se recorrerán los datos de entrenamiento
- La frecuencia de evaluación: La frecuencia para llamar a
evaluate_model
- La iteración de evaluación: El número de lotes a utilizar al evaluar el estado actual del modelo al llamar a
generate_and_print_sample
- El contexto inicial: Qué oración inicial utilizar al llamar a
generate_and_print_sample
- El tokenizador
# 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
Para mejorar la tasa de aprendizaje, hay un par de técnicas relevantes llamadas linear warmup y cosine decay.
Linear warmup consiste en definir una tasa de aprendizaje inicial y una máxima, y actualizarla de manera consistente después de cada época. Esto se debe a que comenzar el entrenamiento con actualizaciones de peso más pequeñas disminuye el riesgo de que el modelo encuentre actualizaciones grandes y desestabilizadoras durante su fase de entrenamiento.
Cosine decay es una técnica que reduce gradualmente la tasa de aprendizaje siguiendo una curva de medio coseno después de la fase de warmup, ralentizando las actualizaciones de peso para minimizar el riesgo de sobrepasar los mínimos de pérdida y asegurar la estabilidad del entrenamiento en fases posteriores.
Nota que estas mejoras no están incluidas en el código anterior.
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.")
Imprimir la evolución del entrenamiento
Con la siguiente función es posible imprimir la evolución del modelo mientras se estaba entrenando.
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)
Guardar el modelo
Es posible guardar el modelo + optimizador si deseas continuar entrenando más tarde:
# 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 el modelo si planeas usarlo:
# 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
Cargando pesos de GPT2
Hay 2 scripts rápidos para cargar los pesos de GPT2 localmente. Para ambos, puedes clonar el repositorio https://github.com/rasbt/LLMs-from-scratch localmente, luego:
- El script https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/gpt_generate.py descargará todos los pesos y transformará los formatos de OpenAI a los que espera nuestro LLM. El script también está preparado con la configuración necesaria y con el prompt: "Every effort moves you"
- El script https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/02_alternative_weight_loading/weight-loading-hf-transformers.ipynb te permite cargar cualquiera de los pesos de GPT2 localmente (solo cambia la variable
CHOOSE_MODEL
) y predecir texto a partir de algunos prompts.