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

  1. 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.

  2. 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.

  3. 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?".

  4. 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.

  5. 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.

  6. 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