Objetivo
Dominar as técnicas modernas de fine-tuning de LLMs: desde o fine-tuning completo até LoRA, QLoRA e Instruction Tuning. Implementar um pipeline completo de adaptação de modelos pré-treinados para tarefas específicas, com foco em eficiência e qualidade.
1. Por que Fine-tuning é Necessário?
Modelos pré-treinados são generalistas — aprenderam estatísticas da linguagem em domínios variados. Para aplicações específicas, fine-tuning oferece:
- Adaptação ao vocabulário e estilo do domínio (medicina, direito, código)
- Calibração para tarefas específicas (classificação, extração, geração estruturada)
- Redução de alucinações em contextos especializados
- Conformidade com formatos de saída específicos (JSON, SQL, etc.)
- Alinhamento com preferências e valores da organização
2. Taxonomia do Fine-tuning Moderno
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from torch.utils.data import Dataset, DataLoader
import math
import warnings
warnings.filterwarnings("ignore")
torch.manual_seed(42)
np.random.seed(42)
sns.set_theme(style="whitegrid")
device = torch.device("cuda" if torch.cuda.is_available()
else "cpu")
print(f"Dispositivo: {device}")
print("=" * 65)
print("TAXONOMIA DO FINE-TUNING MODERNO")
print("=" * 65)
print("""
┌─────────────────────────────────────────────────────────────────┐
│ FINE-TUNING COMPLETO (Full Fine-tuning) │
│ ✓ Melhor performance possível │
│ ✗ Custo computacional ≈ pré-treinamento │
│ ✗ Risco de catastrofic forgetting │
│ Quando usar: dataset grande (>50K), recursos disponíveis │
├─────────────────────────────────────────────────────────────────┤
│ PEFT — Parameter-Efficient Fine-Tuning │
│ │
│ LoRA (Low-Rank Adaptation) ← PADRÃO ATUAL │
│ → Adiciona matrizes de baixo rank ~0.1-1% dos params │
│ → Performance próxima ao full FT │
│ │
│ QLoRA (Quantized LoRA) ← PARA HARDWARE LIMITADO│
│ → LoRA + quantização 4-bit do modelo base │
│ → Reduz VRAM em 4x vs LoRA │
│ │
│ Prefix Tuning / Prompt Tuning │
│ → Aprende soft tokens no início da sequência │
│ → Sem modificar pesos do modelo │
│ │
│ Adapter Layers │
│ → Adiciona pequenas redes entre camadas │
│ → Histórico, substituído por LoRA │
├─────────────────────────────────────────────────────────────────┤
│ INSTRUCTION TUNING │
│ → Fine-tuning em formato instrução-resposta │
│ → Cria modelos que seguem instruções (FLAN, Alpaca, etc.) │
├─────────────────────────────────────────────────────────────────┤
│ RLHF — Reinforcement Learning from Human Feedback │
│ → SFT → Reward Model → PPO │
│ → Cria modelos alinhados a valores humanos (ChatGPT, Claude) │
└─────────────────────────────────────────────────────────────────┘
""")
3. Implementação Completa de LoRA do Zero
class LoRALayer(nn.Module):
"""
Camada LoRA completa com todos os recursos do paper original.
Implementação de referência.
"""
def __init__(self,
in_features: int,
out_features: int,
rank: int = 4,
alpha: float = 1.0,
dropout: float = 0.0,
merge_weights: bool = False):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.rank = rank
self.alpha = alpha
self.escala = alpha / rank
self.merged = False
# Pesos originais (congelados)
self.weight = nn.Parameter(
torch.empty(out_features, in_features),
requires_grad=False
)
self.bias = nn.Parameter(
torch.zeros(out_features),
requires_grad=False
)
nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
# Matrizes de baixo rank (treináveis)
self.lora_A = nn.Parameter(
torch.empty(rank, in_features)
)
self.lora_B = nn.Parameter(
torch.zeros(out_features, rank)
)
# Inicialização: A ~ Normal, B = 0
# Garante ΔW = BA = 0 no início → não perturba o modelo
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
self.dropout = nn.Dropout(dropout)
self.merge_weights = merge_weights
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Saída original
resultado = F.linear(x, self.weight, self.bias)
# Adaptação LoRA: (x × Aᵀ) × Bᵀ × escala
lora_update = F.linear(
F.linear(self.dropout(x), self.lora_A),
self.lora_B
) * self.escala
return resultado + lora_update
def merge(self):
"""
Mescla os pesos LoRA nos pesos originais.
Após merge: inferência mais rápida (sem overhead LoRA).
"""
if not self.merged:
self.weight.data += (
self.lora_B @ self.lora_A
) * self.escala
self.merged = True
def unmerge(self):
"""Desfaz o merge (para continuar treinando)."""
if self.merged:
self.weight.data -= (
self.lora_B @ self.lora_A
) * self.escala
self.merged = False
@property
def n_params_treinaveis(self) -> int:
return self.lora_A.numel() + self.lora_B.numel()
@property
def n_params_total(self) -> int:
return (self.weight.numel() + self.bias.numel()
+ self.n_params_treinaveis)
def __repr__(self) -> str:
return (f"LoRALayer({self.in_features}, {self.out_features}, "
f"rank={self.rank}, alpha={self.alpha})")
# Demonstrar eficiência do LoRA
print("Eficiência do LoRA por rank:")
print(f"{'Rank':>6} {'Params LoRA':>15} {'% do Original':>15} "
f"{'Redução':>12}")
print("-" * 52)
d = 768 # dimensão típica do BERT
for rank in [1, 2, 4, 8, 16, 32, 64]:
params_original = d * d * 2 # Q + V projeções
params_lora = 2 * (d * rank + rank * d) # A + B para Q e V
pct = params_lora / params_original * 100
reducao = 1 - pct / 100
print(f"{rank:>6} {params_lora:>15,} {pct:>14.2f}% "
f"{reducao:>11.1%}")
4. Modelos com LoRA para Fine-tuning
class TransformerComLoRA(nn.Module):
"""
Transformer com LoRA aplicado nas projeções Q, K, V, O.
Arquitetura similar ao que é feito com LLaMA, GPT, etc.
"""
def __init__(self,
vocab_size: int,
d_model: int = 256,
n_heads: int = 4,
n_camadas: int = 4,
d_ff: int = 512,
max_len: int = 128,
dropout: float = 0.1,
lora_rank: int = 4,
lora_alpha: float = 8.0,
aplicar_lora: bool = False):
super().__init__()
self.d_model = d_model
self.vocab_size = vocab_size
# Embeddings
self.embedding = nn.Embedding(vocab_size, d_model,
padding_idx=0)
self.pos_emb = nn.Embedding(max_len, d_model)
self.dropout = nn.Dropout(dropout)
# Camadas Transformer
self.camadas = nn.ModuleList([
CamadaTransformerComLoRA(
d_model, n_heads, d_ff, dropout,
lora_rank, lora_alpha, aplicar_lora
)
for _ in range(n_camadas)
])
self.norm = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size,
bias=False)
self.lm_head.weight = self.embedding.weight
self._inicializar()
def _inicializar(self):
for p in self.parameters():
if p.dim() > 1 and p.requires_grad:
nn.init.normal_(p, std=0.02)
def congelar_base(self):
"""Congela todos os pesos exceto os adapters LoRA."""
for nome, param in self.named_parameters():
if "lora_" in nome:
param.requires_grad = True
else:
param.requires_grad = False
def descongelar_tudo(self):
"""Descongela todos os pesos."""
for param in self.parameters():
param.requires_grad = True
def info_parametros(self) -> dict:
total = sum(p.numel() for p in self.parameters())
treino = sum(p.numel() for p in self.parameters()
if p.requires_grad)
return {
"total": total,
"treino": treino,
"congelado": total - treino,
"pct_treino": treino / total
}
def _criar_mascara_causal(self, seq_len: int,
device: torch.device):
return torch.triu(
torch.ones(seq_len, seq_len, device=device),
diagonal=1
).bool()
def forward(self, x: torch.Tensor) -> torch.Tensor:
batch, seq_len = x.shape
pos = torch.arange(seq_len, device=x.device)
pos = pos.unsqueeze(0).expand(batch, -1)
h = self.dropout(
self.embedding(x) * math.sqrt(self.d_model)
+ self.pos_emb(pos)
)
mask = self._criar_mascara_causal(seq_len, x.device)
for camada in self.camadas:
h = camada(h, mask)
return self.lm_head(self.norm(h))
class CamadaTransformerComLoRA(nn.Module):
"""Camada Transformer com suporte a LoRA nas projeções."""
def __init__(self, d_model, n_heads, d_ff, dropout,
lora_rank, lora_alpha, usar_lora):
super().__init__()
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.d_model = d_model
# Projeções Q, K, V com ou sem LoRA
if usar_lora:
self.W_Q = LoRALayer(d_model, d_model,
rank=lora_rank,
alpha=lora_alpha,
dropout=dropout)
self.W_K = nn.Linear(d_model, d_model)
self.W_V = LoRALayer(d_model, d_model,
rank=lora_rank,
alpha=lora_alpha,
dropout=dropout)
self.W_O = LoRALayer(d_model, d_model,
rank=lora_rank,
alpha=lora_alpha)
else:
self.W_Q = nn.Linear(d_model, d_model)
self.W_K = nn.Linear(d_model, d_model)
self.W_V = nn.Linear(d_model, d_model)
self.W_O = nn.Linear(d_model, d_model)
# K não recebe LoRA (convenção do paper LoRA)
# Congelar K se usando LoRA
if usar_lora:
for p in self.W_K.parameters():
p.requires_grad = False
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.drop = nn.Dropout(dropout)
def _atenção(self, x, mask):
batch, seq, _ = x.shape
Q = self.W_Q(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
K = self.W_K(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
V = self.W_V(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
scores = (Q @ K.transpose(-2,-1)) / math.sqrt(self.d_k)
if mask is not None:
scores = scores.masked_fill(
mask.unsqueeze(0).unsqueeze(0), float("-inf")
)
pesos = F.softmax(scores, dim=-1)
out = (pesos @ V).transpose(1,2).contiguous().view(batch, seq, -1)
return self.W_O(out)
def forward(self, x, mask=None):
x = x + self.drop(self._atenção(self.norm1(x), mask))
x = x + self.drop(self.ffn(self.norm2(x)))
return x
# Comparar modelo base vs modelo com LoRA
print("\nComparação: Modelo Base vs Modelo com LoRA")
print("=" * 55)
# Modelo base
modelo_base = TransformerComLoRA(
vocab_size=500, d_model=128, n_heads=4,
n_camadas=4, d_ff=256, aplicar_lora=False
)
# Modelo com LoRA
modelo_lora = TransformerComLoRA(
vocab_size=500, d_model=128, n_heads=4,
n_camadas=4, d_ff=256, aplicar_lora=True,
lora_rank=4, lora_alpha=8.0
)
modelo_lora.congelar_base()
# Comparar parâmetros
info_base = {
"total": sum(p.numel() for p in modelo_base.parameters()),
"treino": sum(p.numel() for p in modelo_base.parameters() if p.requires_grad),
}
info_lora = modelo_lora.info_parametros()
print(f"\n{'Aspecto':<25} {'Modelo Base':>15} {'Modelo LoRA':>15}")
print("-" * 57)
print(f"{'Total de params':<25} {info_base['total']:>15,} "
f"{info_lora['total']:>15,}")
print(f"{'Params treináveis':<25} {info_base['treino']:>15,} "
f"{info_lora['treino']:>15,}")
print(f"{'% treinável':<25} "
f"{info_base['treino']/info_base['total']:>14.1%} "
f"{info_lora['pct_treino']:>14.1%}")
print(f"{'Redução de params':<25} {'—':>15} "
f"{1-info_lora['treino']/info_base['treino']:>14.1%}")
5. Instruction Tuning
print("\n" + "=" * 65)
print("INSTRUCTION TUNING")
print("=" * 65)
print("""
Instruction Tuning treina o modelo para SEGUIR INSTRUÇÕES,
não apenas completar texto.
Formato padrão (Alpaca/LLaMA):
### Instruction:
Classifique o sentimento do texto abaixo.
### Input:
Este produto é incrível, adorei!
### Response:
Positivo
Sem instruction tuning:
"Classifique o sentimento de: Este produto..."
→ modelo pode continuar gerando mais exemplos
Com instruction tuning:
"Classifique o sentimento de: Este produto..."
→ modelo responde diretamente com a classificação
""")
class InstructionDataset(Dataset):
"""
Dataset para Instruction Tuning.
Formato: instrução + input → resposta
"""
# Templates de instruções sintéticas
TEMPLATES = {
"classificacao_sentimento": {
"instrucao": "Classify the sentiment of the following text as POSITIVE or NEGATIVE.",
"inputs": [
("This product is amazing, I love it!", "POSITIVE"),
("Terrible quality, waste of money.", "NEGATIVE"),
("Best purchase I've ever made!", "POSITIVE"),
("Broken after one day, very disappointed.", "NEGATIVE"),
("Exceeded all my expectations!", "POSITIVE"),
("Poor customer service, never again.", "NEGATIVE"),
("Highly recommend to everyone!", "POSITIVE"),
("Arrived damaged, not as described.", "NEGATIVE"),
]
},
"resumo": {
"instrucao": "Summarize the following text in one sentence.",
"inputs": [
("Machine learning is a method of data analysis that automates analytical model building. It is based on the idea that systems can learn from data.",
"Machine learning enables systems to automatically learn from data."),
("Python is a high-level programming language known for its simplicity and readability. It supports multiple paradigms including object-oriented and functional programming.",
"Python is a simple, readable language supporting multiple programming paradigms."),
]
},
"traducao": {
"instrucao": "Translate the following English text to Portuguese.",
"inputs": [
("Hello, how are you?", "Olá, como vai você?"),
("I love machine learning.", "Eu amo aprendizado de máquina."),
("The weather is beautiful today.", "O tempo está lindo hoje."),
("Thank you very much!", "Muito obrigado!"),
]
},
"qa": {
"instrucao": "Answer the following question based on your knowledge.",
"inputs": [
("What is the capital of Brazil?", "The capital of Brazil is Brasília."),
("What does GPU stand for?", "GPU stands for Graphics Processing Unit."),
("Who invented the telephone?", "The telephone was invented by Alexander Graham Bell."),
]
},
}
def __init__(self, tokenizer_fn, max_len: int = 128,
n_aug: int = 3):
"""
tokenizer_fn: função que converte texto em IDs
n_aug: número de augmentações por exemplo
"""
self.exemplos = []
self.max_len = max_len
self.tokenizer = tokenizer_fn
for tarefa, dados in self.TEMPLATES.items():
instrucao = dados["instrucao"]
for texto, resposta in dados["inputs"]:
# Formatar como prompt de instruction tuning
prompt = self._formatar_prompt(instrucao,
texto,
resposta)
self.exemplos.append(prompt)
def _formatar_prompt(self, instrucao: str,
texto: str, resposta: str) -> str:
"""Formata no padrão Alpaca."""
return (f"### Instruction:\n{instrucao}\n\n"
f"### Input:\n{texto}\n\n"
f"### Response:\n{resposta}")
def __len__(self) -> int:
return len(self.exemplos)
def __getitem__(self, idx: int):
texto = self.exemplos[idx]
ids = self.tokenizer(texto)
ids = ids[:self.max_len]
ids += [0] * (self.max_len - len(ids))
ids_t = torch.tensor(ids, dtype=torch.long)
# Label: mesma sequência deslocada
return ids_t[:-1], ids_t[1:]
# Vocabulário e tokenizer simples para demonstração
VOCAB_ESPECIAL = {"<PAD>": 0, "<UNK>": 1, "<BOS>": 2, "<EOS>": 3}
CHARS = list("abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789.,!?:;'\"-\n#@")
VOCAB_CHAR = {c: i+4 for i, c in enumerate(CHARS)}
VOCAB_CHAR.update(VOCAB_ESPECIAL)
VOCAB_SIZE_IT = len(VOCAB_CHAR)
def tokenizar_char(texto: str) -> list:
"""Tokenizer simples a nível de caracter."""
return [VOCAB_CHAR.get(c, 1) for c in texto]
# Criar dataset e dataloader
ds_it = InstructionDataset(tokenizar_char, max_len=100)
dl_it = DataLoader(ds_it, batch_size=4, shuffle=True)
print(f"Dataset de Instruction Tuning:")
print(f" Exemplos: {len(ds_it)}")
print(f"\nExemplo de prompt formatado:")
print(f" '{ds_it.exemplos[0][:150]}...'")
# Modelo para instruction tuning
modelo_it = TransformerComLoRA(
vocab_size=VOCAB_SIZE_IT,
d_model=64, n_heads=4,
n_camadas=3, d_ff=256,
max_len=100,
aplicar_lora=True,
lora_rank=4, lora_alpha=8.0
).to(device)
modelo_it.congelar_base()
info_it = modelo_it.info_parametros()
print(f"\nModelo para IT:")
print(f" Total: {info_it['total']:,} params")
print(f" Treináveis (LoRA): {info_it['treino']:,} params "
f"({info_it['pct_treino']:.1%})")
# Loop de treino
opt_it = torch.optim.AdamW(
filter(lambda p: p.requires_grad,
modelo_it.parameters()),
lr=1e-3, weight_decay=0.1
)
crit_it = nn.CrossEntropyLoss(ignore_index=0)
hist_it = []
print(f"\nTreinando com Instruction Tuning + LoRA...")
for epoca in range(30):
modelo_it.train()
losses_ep = []
for x_b, y_b in dl_it:
x_b = x_b.to(device)
y_b = y_b.to(device)
opt_it.zero_grad()
logits = modelo_it(x_b)
loss = crit_it(
logits.view(-1, VOCAB_SIZE_IT),
y_b.view(-1)
)
loss.backward()
nn.utils.clip_grad_norm_(
filter(lambda p: p.requires_grad,
modelo_it.parameters()), 1.0
)
opt_it.step()
losses_ep.append(loss.item())
loss_ep = np.mean(losses_ep)
hist_it.append(loss_ep)
if (epoca + 1) % 10 == 0:
print(f" Época {epoca+1:2d}/30 | "
f"Loss: {loss_ep:.4f} | "
f"PPL: {math.exp(loss_ep):.2f}")
6. Currículo de Fine-tuning — Estratégias Avançadas
print("\n" + "=" * 65)
print("ESTRATÉGIAS AVANÇADAS DE FINE-TUNING")
print("=" * 65)
# ── Estratégia 1: Discriminative Learning Rates ──────────
print("\n1. Discriminative Learning Rates:")
print("""
Camadas diferentes recebem learning rates diferentes.
Camadas iniciais (features genéricas) → LR menor
Camadas finais (features específicas) → LR maior
Regra típica: LR multiplica por fator por camada
Camada N: lr_base
Camada N-1: lr_base × 0.9
Camada N-2: lr_base × 0.81
...
""")
def criar_grupos_lr_discriminativo(modelo: nn.Module,
lr_base: float = 1e-4,
fator: float = 0.9) -> list:
"""
Cria grupos de parâmetros com LRs diferentes por camada.
Camadas mais próximas da saída recebem LR maior.
"""
grupos = []
# Identificar camadas em ordem
if hasattr(modelo, "camadas"):
n_camadas = len(modelo.camadas)
for i, camada in enumerate(reversed(modelo.camadas)):
lr_camada = lr_base * (fator ** i)
grupos.append({
"params": list(camada.parameters()),
"lr": lr_camada,
"nome": f"camada_{n_camadas - 1 - i}"
})
# Embedding com LR muito pequeno
if hasattr(modelo, "embedding"):
grupos.append({
"params": list(modelo.embedding.parameters()),
"lr": lr_base * (fator ** n_camadas),
"nome": "embedding"
})
# Cabeça com LR maior
if hasattr(modelo, "lm_head"):
grupos.append({
"params": list(modelo.lm_head.parameters()),
"lr": lr_base * 2,
"nome": "lm_head"
})
return grupos
grupos_lr = criar_grupos_lr_discriminativo(
modelo_it, lr_base=1e-3
)
print(f" Grupos de LR criados:")
for g in grupos_lr:
n_p = sum(p.numel() for p in g["params"] if p.requires_grad)
if n_p > 0:
print(f" {g['nome']:<15}: lr={g['lr']:.6f}, "
f"params={n_p:,}")
# ── Estratégia 2: Gradient Checkpointing ─────────────────
print("\n2. Gradient Checkpointing (para memória):")
print("""
Recomputa ativações durante o backward ao invés de
armazená-las. Reduz memória em ~√n camadas.
Tradeoff: mais compute, menos memória.
Em PyTorch:
model.gradient_checkpointing_enable()
# Ou manualmente:
from torch.utils.checkpoint import checkpoint
x = checkpoint(camada, x)
""")
# ── Estratégia 3: Mixed Precision Training ───────────────
print("\n3. Mixed Precision Training (AMP):")
print("""
Usa float16 para operações forward/backward.
Mantém float32 para atualização dos pesos (precisão).
Reduz memória pela metade e acelera 2-3x em GPUs modernas.
Em PyTorch com GradScaler:
scaler = torch.cuda.amp.GradScaler()
with torch.cuda.amp.autocast():
output = model(input)
loss = criterion(output, target)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
""")
# Demonstração com escaler (sem GPU real, simulado)
if torch.cuda.is_available():
print(" GPU disponível — demonstrando AMP:")
scaler = torch.cuda.amp.GradScaler()
modelo_amp = TransformerComLoRA(
vocab_size=500, d_model=64, n_heads=4,
n_camadas=2, d_ff=128, aplicar_lora=True
).to(device)
modelo_amp.congelar_base()
opt_amp = torch.optim.AdamW(
filter(lambda p: p.requires_grad,
modelo_amp.parameters()),
lr=1e-3
)
x_amp = torch.randint(1, 500, (4, 20)).to(device)
y_amp = torch.randint(1, 500, (4, 20)).to(device)
with torch.cuda.amp.autocast():
logits_amp = modelo_amp(x_amp)
loss_amp = nn.CrossEntropyLoss()(
logits_amp.view(-1, 500), y_amp.view(-1)
)
scaler.scale(loss_amp).backward()
scaler.step(opt_amp)
scaler.update()
print(f" AMP step OK — loss={loss_amp.item():.4f}")
else:
print(" CPU: AMP não disponível sem CUDA")
# ── Estratégia 4: QLoRA (Quantized LoRA) ─────────────────
print("\n4. QLoRA — Quantização + LoRA:")
print("""
QLoRA = Modelo base em 4-bit + LoRA em float16.
Como funciona:
1. Quantiza o modelo base para NF4 (Normal Float 4-bit)
2. Aplica LoRA em float16 sobre o modelo quantizado
3. Usa double quantization para economizar ainda mais
Resultado prático (LLaMA-65B):
Full FT: ~320GB VRAM (proibitivo)
LoRA: ~96GB VRAM
QLoRA: ~48GB VRAM ← torna possível em hardware consumer
Para usar com HuggingFace:
from transformers import BitsAndBytesConfig
config = BitsAndBytesConfig(load_in_4bit=True, ...)
model = AutoModelForCausalLM.from_pretrained(..., quantization_config=config)
""")
7. Avaliação de Modelos Fine-tuned
print("\n" + "=" * 65)
print("AVALIAÇÃO DE MODELOS FINE-TUNED")
print("=" * 65)
# Métricas específicas por tarefa
class AvaliacaoNLP:
"""Coletânea de métricas para avaliação de LLMs."""
@staticmethod
def acuracia(predicoes: list, referencias: list) -> float:
return sum(p == r for p, r in zip(predicoes,
referencias)) / len(referencias)
@staticmethod
def f1_macro(predicoes: list, referencias: list,
classes: list) -> dict:
resultados = {}
for c in classes:
tp = sum(p == c and r == c
for p, r in zip(predicoes, referencias))
fp = sum(p == c and r != c
for p, r in zip(predicoes, referencias))
fn = sum(p != c and r == c
for p, r in zip(predicoes, referencias))
precisao = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = (2 * precisao * recall /
(precisao + recall)
if (precisao + recall) > 0 else 0)
resultados[c] = {"precisao": precisao,
"recall": recall, "f1": f1}
f1_macro = np.mean([v["f1"] for v in resultados.values()])
return {"por_classe": resultados, "macro_f1": f1_macro}
@staticmethod
def bleu_simplificado(candidato: str,
referencia: str,
max_n: int = 4) -> float:
"""
BLEU simplificado para avaliação de geração.
Mede sobreposição de n-gramas.
"""
cand_tokens = candidato.lower().split()
ref_tokens = referencia.lower().split()
if len(cand_tokens) == 0:
return 0.0
scores_n = []
for n in range(1, max_n + 1):
cand_ngrams = set(zip(*[cand_tokens[i:]
for i in range(n)]))
ref_ngrams = set(zip(*[ref_tokens[i:]
for i in range(n)]))
if len(cand_ngrams) == 0:
scores_n.append(0.0)
continue
match = len(cand_ngrams & ref_ngrams)
scores_n.append(match / len(cand_ngrams))
if any(s == 0 for s in scores_n):
return 0.0
# Brevity penalty
bp = min(1.0,
math.exp(1 - len(ref_tokens) /
max(1, len(cand_tokens))))
bleu = bp * math.exp(
np.mean([math.log(s + 1e-10) for s in scores_n])
)
return bleu
@staticmethod
def rouge_l(candidato: str, referencia: str) -> float:
"""ROUGE-L: baseado na Longest Common Subsequence."""
cand = candidato.lower().split()
ref = referencia.lower().split()
if not cand or not ref:
return 0.0
# LCS dinâmico
m, n = len(ref), len(cand)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if ref[i-1] == cand[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
lcs_len = dp[m][n]
precisao = lcs_len / n if n > 0 else 0
recall = lcs_len / m if m > 0 else 0
f1 = (2 * precisao * recall /
(precisao + recall)
if (precisao + recall) > 0 else 0)
return f1
# Demonstrar métricas
avaliador = AvaliacaoNLP()
# Teste de classificação
preds_cls = ["POSITIVE", "NEGATIVE", "POSITIVE",
"NEGATIVE", "POSITIVE", "NEGATIVE"]
refs_cls = ["POSITIVE", "NEGATIVE", "NEGATIVE",
"NEGATIVE", "POSITIVE", "POSITIVE"]
acc = avaliador.acuracia(preds_cls, refs_cls)
f1_res = avaliador.f1_macro(preds_cls, refs_cls,
["POSITIVE", "NEGATIVE"])
print(f"\nAvaliação de Classificação:")
print(f" Acurácia: {acc:.4f}")
print(f" F1 Macro: {f1_res['macro_f1']:.4f}")
for cls, metr in f1_res["por_classe"].items():
print(f" {cls}:")
print(f" Precisão: {metr['precisao']:.4f} | "
f"Recall: {metr['recall']:.4f} | "
f"F1: {metr['f1']:.4f}")
# Teste de geração
pares_geracao = [
("The cat sat on the mat",
"A cat was sitting on the mat"),
("I love programming in Python",
"I enjoy coding with Python"),
("The weather today is sunny and warm",
"It is sunny and warm outside today"),
]
print(f"\nAvaliação de Geração:")
print(f" {'Candidato':<40} {'BLEU':>6} {'ROUGE-L':>8}")
print("-" * 58)
for cand, ref in pares_geracao:
bleu = avaliador.bleu_simplificado(cand, ref)
rouge = avaliador.rouge_l(cand, ref)
print(f" {cand[:38]:<40} {bleu:>6.4f} {rouge:>8.4f}")
8. Pipeline Completo de Fine-tuning
print("\n" + "=" * 65)
print("PIPELINE COMPLETO DE FINE-TUNING")
print("=" * 65)
class FineTuningPipeline:
"""
Pipeline profissional de fine-tuning com LoRA.
Encapsula todo o processo: dados → treino → avaliação → deploy.
"""
def __init__(self,
modelo: nn.Module,
nome_experimento: str = "exp_001"):
self.modelo = modelo
self.nome = nome_experimento
self.historico = {
"loss_tr": [], "loss_va": [],
"ppl_tr": [], "ppl_va": []
}
self.melhor_val_loss = float("inf")
self.epoca_melhor = 0
self.total_steps = 0
def configurar_otimizador(self,
lr: float = 3e-4,
weight_decay: float = 0.1,
betas: tuple = (0.9, 0.95),
usar_dlr: bool = False) -> torch.optim.Optimizer:
"""Configura AdamW com grupos de parâmetros."""
if usar_dlr:
grupos = criar_grupos_lr_discriminativo(
self.modelo, lr_base=lr
)
params_treinaveis = [
g for g in grupos
if sum(p.numel() for p in g["params"]
if p.requires_grad) > 0
]
else:
params_treinaveis = [
p for p in self.modelo.parameters()
if p.requires_grad
]
return torch.optim.AdamW(
params_treinaveis,
lr=lr, weight_decay=weight_decay,
betas=betas
)
def configurar_scheduler(self,
optimizer,
n_steps: int,
warmup_ratio: float = 0.05):
"""Cosine scheduler com warm-up."""
n_warmup = int(n_steps * warmup_ratio)
def lr_lambda(step):
if step < n_warmup:
return step / max(1, n_warmup)
progress = (step - n_warmup) / max(1,
n_steps - n_warmup)
return max(0.1,
0.5 * (1 + math.cos(math.pi * progress)))
return torch.optim.lr_scheduler.LambdaLR(
optimizer, lr_lambda
)
def _epoch_treino(self, dl, optimizer,
scheduler, criterion) -> dict:
"""Uma época de treino."""
self.modelo.train()
losses, n_tokens = [], 0
for x_b, y_b in dl:
x_b = x_b.to(device)
y_b = y_b.to(device)
optimizer.zero_grad()
logits = self.modelo(x_b)
# Loss apenas nos tokens não-padding
loss = criterion(
logits.view(-1, self.modelo.vocab_size),
y_b.view(-1)
)
loss.backward()
# Gradient clipping
nn.utils.clip_grad_norm_(
filter(lambda p: p.requires_grad,
self.modelo.parameters()),
max_norm=1.0
)
optimizer.step()
scheduler.step()
self.total_steps += 1
losses.append(loss.item())
n_tokens += (y_b != 0).sum().item()
avg_loss = np.mean(losses)
return {"loss": avg_loss, "ppl": math.exp(avg_loss)}
@torch.no_grad()
def _epoch_avaliacao(self, dl, criterion) -> dict:
"""Uma época de avaliação."""
self.modelo.eval()
losses = []
for x_b, y_b in dl:
x_b = x_b.to(device)
y_b = y_b.to(device)
logits = self.modelo(x_b)
loss = criterion(
logits.view(-1, self.modelo.vocab_size),
y_b.view(-1)
)
losses.append(loss.item())
avg_loss = np.mean(losses)
return {"loss": avg_loss, "ppl": math.exp(avg_loss)}
def treinar(self,
dl_treino, dl_val,
n_epocas: int = 20,
lr: float = 3e-4,
parada_antecipada: int = 10,
verbose: int = 5) -> "FineTuningPipeline":
"""Loop principal de fine-tuning."""
n_steps = len(dl_treino) * n_epocas
optimizer = self.configurar_otimizador(lr)
scheduler = self.configurar_scheduler(
optimizer, n_steps, warmup_ratio=0.06
)
criterion = nn.CrossEntropyLoss(ignore_index=0)
sem_melhora = 0
print(f"\nFine-tuning: {self.nome}")
print(f" Parâmetros treináveis: "
f"{sum(p.numel() for p in self.modelo.parameters() if p.requires_grad):,}")
print(f" Total de steps: {n_steps:,}")
for epoca in range(1, n_epocas + 1):
metr_tr = self._epoch_treino(
dl_treino, optimizer, scheduler, criterion
)
metr_va = self._epoch_avaliacao(dl_val, criterion)
self.historico["loss_tr"].append(metr_tr["loss"])
self.historico["ppl_tr"].append(metr_tr["ppl"])
self.historico["loss_va"].append(metr_va["loss"])
self.historico["ppl_va"].append(metr_va["ppl"])
# Early stopping
if metr_va["loss"] < self.melhor_val_loss:
self.melhor_val_loss = metr_va["loss"]
self.epoca_melhor = epoca
sem_melhora = 0
torch.save(
{k: v for k, v in self.modelo.state_dict().items()
if "lora_" in k},
f"{self.nome}_lora_melhor.pt"
)
else:
sem_melhora += 1
if sem_melhora >= parada_antecipada:
print(f"\n Early stopping na época {epoca}")
break
if verbose > 0 and epoca % verbose == 0:
lr_atual = optimizer.param_groups[0]["lr"]
print(f" Época {epoca:3d}/{n_epocas} | "
f"Loss: {metr_tr['loss']:.4f}/{metr_va['loss']:.4f} | "
f"PPL: {metr_tr['ppl']:.2f}/{metr_va['ppl']:.2f} | "
f"LR: {lr_atual:.6f}")
print(f"\n Melhor: época {self.epoca_melhor}, "
f"val_loss={self.melhor_val_loss:.4f}, "
f"val_ppl={math.exp(self.melhor_val_loss):.2f}")
return self
def plotar(self):
"""Plota curvas de aprendizado."""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
ep = range(1, len(self.historico["loss_tr"]) + 1)
for ax, metrica, titulo in zip(
axes,
[("loss_tr", "loss_va"), ("ppl_tr", "ppl_va")],
["Loss", "Perplexidade"]
):
ax.plot(ep, self.historico[metrica[0]],
label="Treino", color="steelblue",
linewidth=2)
ax.plot(ep, self.historico[metrica[1]],
label="Validação", color="coral",
linewidth=2, linestyle="--")
ax.axvline(x=self.epoca_melhor, color="green",
linestyle=":", alpha=0.7,
label=f"Melhor (é.{self.epoca_melhor})")
ax.set_xlabel("Época")
ax.set_title(titulo)
ax.legend()
plt.suptitle(f"Fine-tuning: {self.nome}", fontsize=13)
plt.tight_layout()
plt.show()
# Criar datasets de treino e validação
ds_lm_tr = LMDatasetFT(n=1500, seq_len=40)
ds_lm_va = LMDatasetFT(n=300, seq_len=40)
dl_lm_tr = DataLoader(ds_lm_tr, batch_size=32, shuffle=True)
dl_lm_va = DataLoader(ds_lm_va, batch_size=64)
class LMDatasetFT(Dataset):
"""Dataset de LM para fine-tuning."""
def __init__(self, n=1000, seq_len=40, vocab_size=300):
np.random.seed(42)
self.seqs = []
for _ in range(n):
l = np.random.randint(1, 4)
p = list(range(l, min(l * (seq_len + 1),
vocab_size)))
if len(p) < 2:
p = list(range(1, seq_len + 2))
seq = [p[i % len(p)] for i in range(seq_len + 1)]
self.seqs.append(seq)
def __len__(self):
return len(self.seqs)
def __getitem__(self, idx):
s = torch.tensor(self.seqs[idx], dtype=torch.long)
return s[:-1], s[1:]
# Comparar Full FT vs LoRA FT
resultados_comparacao = {}
for nome, aplicar_lora, congelar in [
("Full Fine-tuning", False, False),
("LoRA Fine-tuning", True, True),
]:
torch.manual_seed(42)
modelo_cmp = TransformerComLoRA(
vocab_size=301, d_model=64, n_heads=4,
n_camadas=3, d_ff=256, max_len=42,
aplicar_lora=aplicar_lora,
lora_rank=4, lora_alpha=8.0
).to(device)
if congelar:
modelo_cmp.congelar_base()
pipeline = FineTuningPipeline(modelo_cmp, nome)
pipeline.treinar(
dl_lm_tr, dl_lm_va,
n_epocas=30, lr=3e-4,
parada_antecipada=10,
verbose=10
)
resultados_comparacao[nome] = {
"melhor_val_loss": pipeline.melhor_val_loss,
"melhor_ppl": math.exp(pipeline.melhor_val_loss),
"historico": pipeline.historico,
"n_treinaveis": sum(p.numel() for p in modelo_cmp.parameters()
if p.requires_grad),
}
# Visualização comparativa
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
cores_cmp = ["steelblue", "coral"]
for (nome, res), cor in zip(
resultados_comparacao.items(), cores_cmp
):
ep = range(1, len(res["historico"]["loss_va"]) + 1)
axes[0].plot(ep, res["historico"]["loss_va"],
label=f"{nome} (params={res['n_treinaveis']:,})",
color=cor, linewidth=2)
axes[1].plot(ep, res["historico"]["ppl_va"],
label=nome, color=cor, linewidth=2)
for ax, titulo in zip(axes,
["Validation Loss", "Perplexidade"]):
ax.set_xlabel("Época")
ax.set_title(titulo)
ax.legend(fontsize=9)
plt.suptitle("Full Fine-tuning vs LoRA Fine-tuning",
fontsize=13)
plt.tight_layout()
plt.show()
print("\nResultados Finais:")
print(f"{'Método':<22} {'Val Loss':>10} {'Val PPL':>10} "
f"{'Params Treino':>15}")
print("-" * 60)
for nome, res in resultados_comparacao.items():
print(f"{nome:<22} {res['melhor_val_loss']:>10.4f} "
f"{res['melhor_ppl']:>10.2f} "
f"{res['n_treinaveis']:>15,}")
9. Salvamento e Carregamento de Adapters LoRA
print("\n" + "=" * 65)
print("SALVAMENTO EFICIENTE DE ADAPTERS LORA")
print("=" * 65)
def salvar_adapter_lora(modelo: nn.Module,
caminho: str,
metadata: dict = None):
"""
Salva apenas os pesos do adapter LoRA (muito menor que o modelo completo).
"""
# Extrair apenas pesos LoRA
pesos_lora = {
nome: param.data.clone()
for nome, param in modelo.named_parameters()
if "lora_" in nome
}
checkpoint = {
"pesos_lora": pesos_lora,
"n_params": sum(p.numel() for p in pesos_lora.values()),
"metadata": metadata or {}
}
torch.save(checkpoint, caminho)
size_kb = sum(p.numel() * 4 for p in pesos_lora.values()) / 1024
print(f" Adapter salvo: {len(pesos_lora)} tensores, "
f"~{size_kb:.1f} KB")
return checkpoint
def carregar_adapter_lora(modelo: nn.Module,
caminho: str) -> nn.Module:
"""
Carrega adapter LoRA em um modelo base congelado.
"""
checkpoint = torch.load(caminho, weights_only=True)
pesos_lora = checkpoint["pesos_lora"]
# Verificar compatibilidade
pesos_modelo = {nome: param
for nome, param in modelo.named_parameters()
if "lora_" in nome}
faltando = set(pesos_lora.keys()) - set(pesos_modelo.keys())
extras = set(pesos_modelo.keys()) - set(pesos_lora.keys())
if faltando:
print(f" Aviso: {len(faltando)} tensores faltando")
if extras:
print(f" Aviso: {len(extras)} tensores extras")
# Carregar pesos
estado_atual = modelo.state_dict()
estado_atual.update(pesos_lora)
modelo.load_state_dict(estado_atual)
print(f" Adapter carregado: {len(pesos_lora)} tensores")
return modelo
# Demonstrar salvamento
modelo_demo = TransformerComLoRA(
vocab_size=301, d_model=64, n_heads=4,
n_camadas=3, d_ff=256, aplicar_lora=True
)
modelo_demo.congelar_base()
n_total = sum(p.numel() for p in modelo_demo.parameters())
n_lora = sum(p.numel() for p in modelo_demo.parameters()
if "lora_" in [n for n, _ in
modelo_demo.named_parameters()
if id(_) == id(p)][0:1])
n_lora2 = sum(p.numel() for nome, p in
modelo_demo.named_parameters()
if "lora_" in nome)
ckpt = salvar_adapter_lora(
modelo_demo, "adapter_demo.pt",
metadata={
"rank": 4, "alpha": 8.0,
"tarefa": "classificacao_sentimento",
"val_ppl": 12.5
}
)
print(f"\nComparação de tamanho:")
size_full = n_total * 4 / 1024
size_adapter = ckpt["n_params"] * 4 / 1024
print(f" Modelo completo: ~{size_full:.1f} KB")
print(f" Adapter LoRA: ~{size_adapter:.1f} KB")
print(f" Redução: {1-size_adapter/size_full:.1%}")
# Visualização final das curvas
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for (nome, res), cor in zip(
resultados_comparacao.items(),
["steelblue", "coral"]
):
ep = range(1, len(res["historico"]["loss_tr"]) + 1)
axes[0].plot(ep, res["historico"]["loss_tr"],
label=f"{nome} (tr)", color=cor,
linewidth=2, alpha=0.5)
axes[0].plot(ep, res["historico"]["loss_va"],
label=f"{nome} (va)", color=cor,
linewidth=2, linestyle="--")
axes[0].set_xlabel("Época")
axes[0].set_ylabel("Loss")
axes[0].set_title("Curvas de Treinamento")
axes[0].legend(fontsize=8)
nomes_r = list(resultados_comparacao.keys())
ppls_r = [resultados_comparacao[n]["melhor_ppl"] for n in nomes_r]
params_r = [resultados_comparacao[n]["n_treinaveis"] for n in nomes_r]
cores_bar = ["steelblue", "coral"]
axes[1].bar(nomes_r, ppls_r, color=cores_bar, alpha=0.85)
axes[1].set_title("Melhor Perplexidade\n(menor = melhor)")
axes[1].set_ylabel("Perplexidade")
for i, (p, n) in enumerate(zip(ppls_r, params_r)):
axes[1].text(i, p * 1.02, f"PPL={p:.1f}\n{n:,} params",
ha="center", fontsize=9)
plt.suptitle("Resumo: Estratégias de Fine-tuning", fontsize=13)
plt.tight_layout()
plt.show()
Resumo da Aula
- Fine-tuning adapta LLMs pré-treinados para tarefas específicas sem treinar do zero
- Full Fine-tuning maximiza a performance mas é computacionalmente caro e pode causar catastrophic forgetting
- LoRA reduz parâmetros treináveis em >95% adicionando matrizes de baixo rank às projeções de atenção
- A inicialização B=0 garante que ΔW=BA=0 no início — o modelo começa idêntico ao pré-treinado
- QLoRA combina quantização 4-bit do modelo base com LoRA, tornando fine-tuning de modelos grandes viável em hardware consumer
- Instruction Tuning formata os dados como instrução/input/resposta, criando modelos que seguem instruções
- Discriminative Learning Rates aplicam LR menor nas primeiras camadas, onde features são mais genéricas
- Mixed Precision Training usa float16 no forward/backward e float32 nos pesos, reduzindo memória à metade
- BLEU mede sobreposição de n-gramas entre geração e referência; ROUGE-L mede a subsequência comum mais longa
- Salvar apenas os adapters LoRA é muito mais eficiente que salvar o modelo completo
Exercícios
-
Explique matematicamente por que a inicialização B=0 no LoRA é importante. O que aconteceria se você inicializasse B com valores aleatórios ao invés de zeros?
✓ Resposta:Com B=0, o update inicial ΔW = B × A = 0 × A = 0. Isso significa que no início do fine-tuning, o modelo com LoRA é matematicamente idêntico ao modelo pré-treinado. A adaptação começa do zero de forma incremental.
Se B fosse inicializado aleatoriamente, ΔW = B × A seria um vetor não-nulo aleatório adicionado aos pesos originais imediatamente. Isso perturbaria o modelo logo no início, potencialmente destruindo representações cuidadosamente aprendidas durante o pré-treinamento. O modelo começaria o fine-tuning de um estado degradado, precisando primeiro "desfazer" a perturbação aleatória antes de aprender a adaptação útil.
A inicialização B=0 é elegante porque garante que qualquer melhoria vem puramente da adaptação aprendida, não de uma perturbação inicial que o modelo precisa compensar.
-
O que é catastrophic forgetting em fine-tuning completo? Por que o LoRA é mais resistente a esse problema? Quais outras estratégias existem para mitigá-lo no full fine-tuning?
✓ Resposta:Catastrophic forgetting ocorre quando o modelo, ao aprender uma nova tarefa, "sobrescreve" os pesos que codificavam conhecimento das tarefas anteriores. No full fine-tuning, os gradientes da nova tarefa atualizam os mesmos pesos que codificam o conhecimento geral, potencialmente degradando a performance em tarefas não relacionadas.
O LoRA é mais resistente porque os pesos originais são congelados — apenas as matrizes adicionais A e B são treinadas. O conhecimento geral do pré-treinamento permanece intacto nos pesos congelados, e as matrizes LoRA adicionam apenas um "delta" especializado. O modelo base nunca muda.
Outras estratégias para mitigar catastrophic forgetting no full fine-tuning: EWC (Elastic Weight Consolidation) que adiciona penalidade baseada na importância dos pesos para tarefas anteriores; Replay de dados misturando exemplos da nova tarefa com exemplos do pré-treinamento; Learning rate muito baixo que limita a magnitude dos updates; e Knowledge Distillation que treina o modelo fine-tuned para manter saídas similares ao modelo original em distribuições gerais.
-
Explique o conceito de Instruction Tuning. Por que um modelo treinado apenas com language modeling (prever próximo token) não segue instruções naturalmente? O que muda no formato dos dados?
✓ Resposta:Um modelo treinado apenas com language modeling aprende a continuar texto de forma coerente, não a responder instruções. Dado o prompt "Traduza para português: Hello", o modelo pode gerar "How are you? Goodbye. Thank you." — continuando o padrão textual de exemplos de frases em inglês, sem entender que deve traduzir.
Instruction Tuning muda o formato dos dados: em vez de texto contínuo, cada exemplo é um triplo (instrução, input, resposta). O modelo aprende a associar padrões do formato "### Instruction: ... ### Response:" com o comportamento de seguir a instrução. Após ver milhares de exemplos desse formato, o modelo generaliza — dado qualquer nova instrução no mesmo formato, responde ao invés de continuar gerando texto genérico.
A mudança é fundamental: language modeling puro aprende "qual texto segue naturalmente?", enquanto instruction tuning aprende "qual resposta corresponde a esta instrução?".
-
Compare as métricas BLEU e ROUGE-L para avaliação de geração de texto. Quais são as limitações de cada uma? Por que métricas automáticas são insuficientes para avaliar LLMs modernos?
✓ Resposta:BLEU mede a precisão de n-gramas do candidato em relação à referência. ROUGE-L mede o recall via Longest Common Subsequence — quanto da referência está presente no candidato.
Limitações do BLEU: penaliza paráfrases válidas (usa palavras diferentes mas corretas), não captura semântica (duas palavras similares contam como diferentes), e não considera fluência gramatical além das n-gramas.
Limitações do ROUGE: otimizável artificialmente ao produzir textos longos que cobrem mais n-gramas da referência, não avalia se o conteúdo é factualmente correto, e depende fortemente da qualidade das referências humanas.
Métricas automáticas são insuficientes para LLMs modernos porque: a "resposta correta" pode ter inúmeras formulações equivalentes não capturadas por uma única referência; qualidade de raciocínio não é capturável por sobreposição de n-gramas; factualidade e ausência de alucinações requerem verificação semântica; e alinhamento a valores (segurança, helpfulness, harmlessness) é impossível medir automaticamente. Por isso, avaliações modernas de LLMs incluem benchmarks de raciocínio (MMLU, HellaSwag), avaliação por modelos maiores (GPT-4 como juiz) e estudos com usuários humanos.
-
O que é QLoRA e como ele permite treinar modelos de 65B parâmetros em hardware consumer? Quais são os trade-offs em termos de velocidade, qualidade e memória comparado ao LoRA padrão?
✓ Resposta:QLoRA (Quantized LoRA) usa o modelo base carregado em precisão NF4 (Normal Float 4-bit), que representa cada peso com apenas 4 bits ao invés de 16 ou 32. Com 4 bits, o modelo base usa 4x menos memória. As matrizes LoRA adicionadas são mantidas em float16 para gradientes precisos.
Para LLaMA-65B: em float16 ocupa ~130GB de VRAM, inacessível para a maioria. Em 4-bit NF4, ocupa ~33GB — cabe em hardware de pesquisa mais acessível. Os adapters LoRA adicionam poucos GB a mais.
Trade-offs comparado ao LoRA padrão:
Velocidade: mais lento, pois operações 4-bit não são tão otimizadas em hardware atual quanto float16. A dequantização para float16 antes de cada operação adiciona latência.
Qualidade: ligeiramente inferior ao LoRA em float16, mas o paper original demonstrou que a diferença é pequena para a maioria das tarefas — o modelo NF4 mantém boa parte das capacidades do modelo original.
Memória: significativamente menor, tornando possível fine-tuning de modelos que de outra forma seriam inacessíveis. Double quantization (quantizar os fatores de quantização também) reduz ainda mais o uso de memória.
-
Descreva o que é RLHF (Reinforcement Learning from Human Feedback) e por que é necessário além do Instruction Tuning. Quais são os três componentes principais (SFT, RM, PPO) e o papel de cada um?
✓ Resposta:RLHF alinha o comportamento do LLM a preferências humanas além do que o Instruction Tuning consegue. IT ensina o modelo a seguir instruções, mas não garante que as respostas sejam úteis, seguras e honestas — um modelo pode seguir uma instrução de forma tecnicamente correta mas produzir conteúdo prejudicial ou enganoso.
Os três componentes:
SFT (Supervised Fine-Tuning): primeiro passo, equivale ao Instruction Tuning. O modelo aprende a responder instruções a partir de demonstrações humanas de alta qualidade. Cria o "modelo SFT" que sabe o formato esperado.
Reward Model (RM): um classificador separado que aprende a prever qual de duas respostas os humanos preferem. É treinado em dados de preferência: pares (resposta A, resposta B) anotados por humanos que indicam qual é melhor. O RM aprende a quantificar "qualidade da resposta" como um escalar.
PPO (Proximal Policy Optimization): o modelo SFT é refinado usando o RM como função de recompensa. O modelo gera respostas, o RM as avalia, e o PPO atualiza o modelo para maximizar a recompensa média — com uma restrição que impede que o modelo se afaste demais do SFT inicial (KL divergence penalty), evitando que "jogue" o RM de formas indesejadas.
O resultado é um modelo que não apenas segue instruções (IT) mas as segue de forma que humanos consideram útil, honesta e segura.
Referências
- LoRA paper (Hu et al., 2021)
- QLoRA paper (Dettmers et al., 2023)
- InstructGPT / RLHF paper (Ouyang et al., 2022)
- Alpaca: Fine-Tuning LLaMA (Stanford)
- FLAN (Wei et al., 2021)
- Hugging Face PEFT library
- Hugging Face TRL (RLHF)
- BLEU paper (Papineni et al., 2002)
- ROUGE (Lin, 2004)
- Tim Dettmers Blog — QLoRA