6. Pré-entraînement et chargement des modèles
Reading time: 25 minutes
Génération de texte
Pour entraîner un modèle, nous aurons besoin que ce modèle soit capable de générer de nouveaux tokens. Ensuite, nous comparerons les tokens générés avec ceux attendus afin d'entraîner le modèle à apprendre les tokens qu'il doit générer.
Comme dans les exemples précédents où nous avons déjà prédit certains tokens, il est possible de réutiliser cette fonction à cette fin.
tip
L'objectif de cette sixième phase est très simple : Entraîner le modèle depuis le début. Pour cela, l'architecture LLM précédente sera utilisée avec quelques boucles parcourant les ensembles de données en utilisant les fonctions de perte et l'optimiseur définis pour entraîner tous les paramètres du modèle.
Évaluation de texte
Pour effectuer un entraînement correct, il est nécessaire de mesurer les prédictions obtenues pour le token attendu. L'objectif de l'entraînement est de maximiser la probabilité du token correct, ce qui implique d'augmenter sa probabilité par rapport aux autres tokens.
Pour maximiser la probabilité du token correct, les poids du modèle doivent être modifiés afin que cette probabilité soit maximisée. Les mises à jour des poids se font via backpropagation. Cela nécessite une fonction de perte à maximiser. Dans ce cas, la fonction sera la différence entre la prédiction effectuée et celle désirée.
Cependant, au lieu de travailler avec les prédictions brutes, il travaillera avec un logarithme de base n. Ainsi, si la prédiction actuelle du token attendu était 7.4541e-05, le logarithme naturel (base e) de 7.4541e-05 est d'environ -9.5042.
Ensuite, pour chaque entrée avec une longueur de contexte de 5 tokens par exemple, le modèle devra prédire 5 tokens, les 4 premiers tokens étant les derniers de l'entrée et le cinquième étant celui prédit. Par conséquent, pour chaque entrée, nous aurons 5 prédictions dans ce cas (même si les 4 premiers étaient dans l'entrée, le modèle ne le sait pas) avec 5 tokens attendus et donc 5 probabilités à maximiser.
Par conséquent, après avoir effectué le logarithme naturel sur chaque prédiction, la moyenne est calculée, le symbole moins retiré (ceci est appelé cross entropy loss) et c'est le nombre à réduire aussi près de 0 que possible car le logarithme naturel de 1 est 0 :
 (1).png)
Une autre façon de mesurer la qualité du modèle est appelée perplexité. Perplexité est une métrique utilisée pour évaluer à quel point un modèle de probabilité prédit un échantillon. Dans la modélisation du langage, elle représente l'incertitude du modèle lors de la prédiction du prochain token dans une séquence.
Par exemple, une valeur de perplexité de 48725 signifie que lorsqu'il doit prédire un token, il n'est pas sûr de savoir lequel parmi 48 725 tokens dans le vocabulaire est le bon.
Exemple de pré-entraînement
Voici le code initial proposé dans https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/ch05.ipynb parfois légèrement modifié
Code précédent utilisé ici mais déjà expliqué dans les sections précédentes
"""
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"
)
Fonctions pour transformer le texte <--> ids
Ce sont quelques fonctions simples qui peuvent être utilisées pour transformer des textes du vocabulaire en ids et vice versa. Cela est nécessaire au début du traitement du texte et à la fin des prédictions :
# 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())
Générer des fonctions de texte
Dans une section précédente, une fonction qui obtenait simplement le token le plus probable après avoir obtenu les logits. Cependant, cela signifie que pour chaque entrée, la même sortie sera toujours générée, ce qui la rend très déterministe.
La fonction generate_text
suivante appliquera les concepts de top-k
, temperature
et multinomial
.
- Le
top-k
signifie que nous allons commencer par réduire à-inf
toutes les probabilités de tous les tokens sauf pour les k tokens les plus élevés. Donc, si k=3, avant de prendre une décision, seuls les 3 tokens les plus probables auront une probabilité différente de-inf
. - La
temperature
signifie que chaque probabilité sera divisée par la valeur de température. Une valeur de0.1
améliorera la probabilité la plus élevée par rapport à la plus basse, tandis qu'une température de5
, par exemple, la rendra plus plate. Cela aide à améliorer la variation des réponses que nous aimerions que le LLM ait. - Après avoir appliqué la température, une fonction
softmax
est appliquée à nouveau pour faire en sorte que tous les tokens restants aient une probabilité totale de 1. - Enfin, au lieu de choisir le token avec la plus grande probabilité, la fonction
multinomial
est appliquée pour prédire le prochain token selon les probabilités finales. Donc, si le token 1 avait 70% de probabilités, le token 2 20% et le token 3 10%, 70% du temps le token 1 sera sélectionné, 20% du temps ce sera le token 2 et 10% du temps ce sera le 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
Il existe une alternative courante à top-k
appelée top-p
, également connue sous le nom d'échantillonnage par noyau, qui au lieu de prendre k échantillons avec la plus grande probabilité, organise tout le vocabulaire résultant par probabilités et somme celles-ci de la plus haute probabilité à la plus basse jusqu'à ce qu'un seuil soit atteint.
Ensuite, seules ces mots du vocabulaire seront considérés en fonction de leurs probabilités relatives.
Cela permet de ne pas avoir besoin de sélectionner un nombre d'échantillons k
, car le k optimal peut être différent dans chaque cas, mais seulement un seuil.
Notez que cette amélioration n'est pas incluse dans le code précédent.
tip
Une autre façon d'améliorer le texte généré est d'utiliser Beam search au lieu de la recherche gloutonne utilisée dans cet exemple.
Contrairement à la recherche gloutonne, qui sélectionne le mot suivant le plus probable à chaque étape et construit une seule séquence, la recherche par faisceau garde une trace des 𝑘 k séquences partielles les mieux notées (appelées "faisceaux") à chaque étape. En explorant plusieurs possibilités simultanément, elle équilibre efficacité et qualité, augmentant les chances de trouver une meilleure séquence globale qui pourrait être manquée par l'approche gloutonne en raison de choix précoces et sous-optimaux.
Notez que cette amélioration n'est pas incluse dans le code précédent.
Fonctions de perte
La fonction calc_loss_batch
calcule l'entropie croisée d'une prédiction d'un seul lot.
La calc_loss_loader
obtient l'entropie croisée de tous les lots et calcule l'entropie croisée moyenne.
# 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
Le clipping de gradient est une technique utilisée pour améliorer la stabilité de l'entraînement dans de grands réseaux de neurones en fixant un seuil maximum pour les magnitudes des gradients. Lorsque les gradients dépassent ce max_norm
prédéfini, ils sont réduits proportionnellement pour garantir que les mises à jour des paramètres du modèle restent dans une plage gérable, évitant des problèmes comme les gradients explosifs et assurant un entraînement plus contrôlé et stable.
Notez que cette amélioration n'est pas incluse dans le code précédent.
Consultez l'exemple suivant :
 (1).png)
Chargement des données
Les fonctions create_dataloader_v1
et create_dataloader_v1
ont déjà été discutées dans une section précédente.
À partir de là, notez comment il est défini que 90 % du texte sera utilisé pour l'entraînement tandis que 10 % sera utilisé pour la validation et que les deux ensembles sont stockés dans 2 chargeurs de données différents.
Notez que parfois, une partie de l'ensemble de données est également laissée pour un ensemble de test afin d'évaluer mieux la performance du modèle.
Les deux chargeurs de données utilisent la même taille de lot, longueur maximale, stride et nombre de travailleurs (0 dans ce cas).
Les principales différences résident dans les données utilisées par chacun, et le validateur ne supprime pas le dernier ni ne mélange les données car cela n'est pas nécessaire à des fins de validation.
De plus, le fait que le stride soit aussi grand que la longueur du contexte signifie qu'il n'y aura pas de chevauchement entre les contextes utilisés pour entraîner les données (réduit le surapprentissage mais aussi l'ensemble de données d'entraînement).
En outre, notez que la taille du lot dans ce cas est de 2 pour diviser les données en 2 lots, l'objectif principal étant de permettre un traitement parallèle et de réduire la consommation par lot.
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
)
Vérifications de cohérence
L'objectif est de vérifier qu'il y a suffisamment de tokens pour l'entraînement, que les formes sont celles attendues et d'obtenir des informations sur le nombre de tokens utilisés pour l'entraînement et pour la validation :
# 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)
Sélectionner un appareil pour l'entraînement et les pré-calculs
Le code suivant sélectionne simplement l'appareil à utiliser et calcule une perte d'entraînement et une perte de validation (sans avoir encore entraîné quoi que ce soit) comme point de départ.
# 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)
Fonctions d'entraînement
La fonction generate_and_print_sample
va simplement obtenir un contexte et générer quelques tokens afin d'avoir une idée de la performance du modèle à ce moment-là. Cela est appelé par train_model_simple
à chaque étape.
La fonction evaluate_model
est appelée aussi souvent que l'indique la fonction d'entraînement et elle est utilisée pour mesurer la perte d'entraînement et la perte de validation à ce moment dans l'entraînement du modèle.
Ensuite, la grande fonction train_model_simple
est celle qui entraîne réellement le modèle. Elle attend :
- Le chargeur de données d'entraînement (avec les données déjà séparées et préparées pour l'entraînement)
- Le chargeur de validation
- L'optimiseur à utiliser pendant l'entraînement : C'est la fonction qui utilisera les gradients et mettra à jour les paramètres pour réduire la perte. Dans ce cas, comme vous le verrez,
AdamW
est utilisé, mais il en existe beaucoup d'autres. optimizer.zero_grad()
est appelé pour réinitialiser les gradients à chaque tour afin de ne pas les accumuler.- Le paramètre
lr
est le taux d'apprentissage qui détermine la taille des étapes prises pendant le processus d'optimisation lors de la mise à jour des paramètres du modèle. Un taux d'apprentissage plus petit signifie que l'optimiseur effectue des mises à jour plus petites des poids, ce qui peut conduire à une convergence plus précise mais peut ralentir l'entraînement. Un taux d'apprentissage plus grand peut accélérer l'entraînement mais risque de dépasser le minimum de la fonction de perte (sauter par-dessus le point où la fonction de perte est minimisée). - La décroissance de poids modifie l'étape de calcul de la perte en ajoutant un terme supplémentaire qui pénalise les poids importants. Cela encourage l'optimiseur à trouver des solutions avec des poids plus petits, équilibrant entre un bon ajustement des données et le maintien d'un modèle simple, prévenant le surapprentissage dans les modèles d'apprentissage automatique en décourageant le modèle d'accorder trop d'importance à une seule caractéristique.
- Les optimisateurs traditionnels comme SGD avec régularisation L2 couplent la décroissance de poids avec le gradient de la fonction de perte. Cependant, AdamW (une variante de l'optimiseur Adam) découple la décroissance de poids de la mise à jour du gradient, conduisant à une régularisation plus efficace.
- Le dispositif à utiliser pour l'entraînement
- Le nombre d'époques : Nombre de fois à passer sur les données d'entraînement
- La fréquence d'évaluation : La fréquence d'appel de
evaluate_model
- L'itération d'évaluation : Le nombre de lots à utiliser lors de l'évaluation de l'état actuel du modèle lors de l'appel de
generate_and_print_sample
- Le contexte de départ : Quelle phrase de départ utiliser lors de l'appel de
generate_and_print_sample
- Le tokenizer
# 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
Pour améliorer le taux d'apprentissage, il existe quelques techniques pertinentes appelées linear warmup et cosine decay.
Linear warmup consiste à définir un taux d'apprentissage initial et un maximum, puis à le mettre à jour de manière cohérente après chaque époque. Cela est dû au fait que commencer l'entraînement avec des mises à jour de poids plus petites diminue le risque que le modèle rencontre de grandes mises à jour déstabilisantes pendant sa phase d'entraînement.
Cosine decay est une technique qui réduit progressivement le taux d'apprentissage suivant une courbe demi-cosinus après la phase de warmup, ralentissant les mises à jour de poids pour minimiser le risque de dépasser les minima de perte et garantir la stabilité de l'entraînement dans les phases ultérieures.
Notez que ces améliorations ne sont pas incluses dans le code précédent.
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.")
Imprimer l'évolution de l'entraînement
Avec la fonction suivante, il est possible d'imprimer l'évolution du modèle pendant qu'il était en cours d'entraînement.
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)
Sauvegarder le modèle
Il est possible de sauvegarder le modèle + l'optimiseur si vous souhaitez continuer l'entraînement plus tard :
# 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
Ou juste le modèle si vous prévoyez de l'utiliser uniquement :
# 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
Chargement des poids GPT2
Il y a 2 scripts rapides pour charger les poids GPT2 localement. Pour les deux, vous pouvez cloner le dépôt https://github.com/rasbt/LLMs-from-scratch localement, puis :
- Le script https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/01_main-chapter-code/gpt_generate.py téléchargera tous les poids et transformera les formats d'OpenAI vers ceux attendus par notre LLM. Le script est également préparé avec la configuration nécessaire et avec le prompt : "Every effort moves you"
- Le script https://github.com/rasbt/LLMs-from-scratch/blob/main/ch05/02_alternative_weight_loading/weight-loading-hf-transformers.ipynb vous permet de charger n'importe lequel des poids GPT2 localement (il suffit de changer la variable
CHOOSE_MODEL
) et de prédire du texte à partir de certains prompts.